Claude Code Boilerplate
FeaturesPricingBlogDocs
Get started →

Product

  • Features
  • Pricing
  • Skills

Compare

  • vs ShipFast
  • vs MakerKit
  • vs supastarter

Resources

  • Docs
  • Blog
  • Discord

Legal

  • License
  • Privacy Policy
  • Terms of Service
Claude Code Boilerplate

© 2026 Claude Code Boilerplate. All rights reserved.

← All posts

Stripe subscriptions in Next.js -- checkout, webhooks, and customer portal

May 28, 2026
stripenextjssaaswebhooks

Stripe subscriptions in Next.js -- checkout, webhooks, and customer portal

Every SaaS needs billing. Stripe is the default choice, but wiring it up correctly -- checkout, webhooks syncing to your DB, and a customer portal -- takes more than copy-pasting from the docs. This post walks through the pattern used in the boilerplate.

The three moving parts

Stripe subscriptions require three things to work end to end:

  1. A checkout session that redirects the user to Stripe
  2. A webhook listener that updates your database when Stripe fires events
  3. A customer portal that lets users manage their subscription

1. Create a checkout session

The checkout route is a thin API handler that calls your service layer:

// app/api/stripe/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getUserFromRequest } from '@/lib/auth';
import { stripeService } from '@/modules/stripe/stripe.service';
import { handleError } from '@/lib/errors';
 
export async function POST(req: NextRequest) {
  try {
    const user = await getUserFromRequest(req);
    const { priceId } = await req.json();
    const url = await stripeService.createCheckoutSession(user.id, priceId);
    return NextResponse.json({ url });
  } catch (error: unknown) {
    return handleError(error);
  }
}

The service creates a Stripe customer if one does not exist, then creates the session:

// modules/stripe/stripe.service.ts
import Stripe from 'stripe';
import { userRepo } from '@/modules/user/user.repo';
import { HttpError } from '@/lib/errors';
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
 
export const stripeService = {
  async createCheckoutSession(userId: string, priceId: string): Promise<string> {
    const user = await userRepo.findById(userId);
    if (!user) throw new HttpError(404, 'User not found');
 
    let customerId = user.stripeCustomerId;
    if (!customerId) {
      const customer = await stripe.customers.create({ email: user.email });
      customerId = customer.id;
      await userRepo.update(userId, { stripeCustomerId: customerId });
    }
 
    const session = await stripe.checkout.sessions.create({
      customer: customerId,
      mode: 'subscription',
      line_items: [{ price: priceId, quantity: 1 }],
      success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/dashboard?checkout=success`,
      cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/pricing`,
    });
 
    return session.url!;
  },
};

Two things to note: the customer ID is stored in your DB rather than looked up from Stripe on every request, and the service throws HttpError -- never returns error objects.

2. Handle webhooks

Webhooks are where most implementations break. Stripe fires events asynchronously -- your DB must stay in sync.

// app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { stripeService } from '@/modules/stripe/stripe.service';
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
 
export async function POST(req: NextRequest) {
  const body = await req.text();
  const sig = req.headers.get('stripe-signature')!;
 
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
  }
 
  switch (event.type) {
    case 'customer.subscription.created':
    case 'customer.subscription.updated':
      await stripeService.syncSubscription(event.data.object as Stripe.Subscription);
      break;
    case 'customer.subscription.deleted':
      await stripeService.cancelSubscription(event.data.object as Stripe.Subscription);
      break;
  }
 
  return NextResponse.json({ received: true });
}

The syncSubscription method maps the Stripe subscription back to your users table:

async syncSubscription(subscription: Stripe.Subscription): Promise<void> {
  const user = await userRepo.findByStripeCustomerId(subscription.customer as string);
  if (!user) return;
 
  await userRepo.update(user.id, {
    subscriptionId: subscription.id,
    subscriptionStatus: subscription.status,
    subscriptionPriceId: subscription.items.data[0].price.id,
  });
},

Store the raw Stripe status string (active, past_due, canceled) -- never map it to your own enum. Stripe's values are stable and you can check them directly in your service layer.

3. Customer portal

The portal lets users update payment methods, upgrade, or cancel -- no UI to build on your side:

async createPortalSession(userId: string): Promise<string> {
  const user = await userRepo.findById(userId);
  if (!user?.stripeCustomerId) throw new HttpError(400, 'No billing account');
 
  const session = await stripe.billingPortal.sessions.create({
    customer: user.stripeCustomerId,
    return_url: `${process.env.NEXT_PUBLIC_BASE_URL}/dashboard`,
  });
 
  return session.url;
},

Schema changes

Add three columns to your users table:

// modules/user/user.schema.ts (additions)
stripeCustomerId: text('stripe_customer_id'),
subscriptionId: text('subscription_id'),
subscriptionStatus: text('subscription_status'),

Then generate and apply the migration:

npm run db:generate
npm run db:migrate

Testing webhooks locally

Use the Stripe CLI to forward events to your local server:

stripe listen --forward-to localhost:3000/api/stripe/webhook

Copy the webhook signing secret it prints and set it as STRIPE_WEBHOOK_SECRET in .env. This is the only webhook secret you should use locally -- do not use the dashboard secret until production.

What to check before shipping

  • Idempotency: Stripe can deliver the same webhook twice. syncSubscription handles this because it does an upsert, not an insert.
  • Auth on portal: the portal route must call getUserFromRequest -- never expose a portal URL without verifying the session.
  • Env vars: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and NEXT_PUBLIC_STRIPE_PRICE_* must be set in Vercel before the first real charge.

Next step

Add a requireSubscription guard to any route that should be accessible only to paying users:

export async function requireSubscription(userId: string) {
  const user = await userRepo.findById(userId);
  if (user?.subscriptionStatus !== 'active') {
    throw new HttpError(402, 'Active subscription required');
  }
}

Call it at the top of any service method that should be gated. The boilerplate's stripe-setup skill scaffolds all of this automatically -- run /stripe-setup in Claude Code to wire the full checkout, webhook, and portal flow into your project in one command.