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.
Stripe subscriptions require three things to work end to end:
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.
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.
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;
},
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
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.
syncSubscription handles this because it does an upsert, not an insert.getUserFromRequest -- never expose a portal URL without verifying the session.STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and NEXT_PUBLIC_STRIPE_PRICE_* must be set in Vercel before the first real charge.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.