Every SaaS project eventually needs the same thing, a way to charge people. Stripe handles the actual payment processing, but wiring it into Next.js correctly, especially getting webhooks right, is where most of the real work sits.
Here is the setup I use for checkout and subscriptions in every SaaS project.
1. The Stripe Client
// lib/stripe.ts
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
apiVersion: '2024-11-20.acacia',
typescript: true,
});
One client, imported wherever needed. Nothing fancy, but keeping it in one file means the API version and config only need to be set once.
2. Creating a Checkout Session
The checkout flow starts with a Server Action that creates a Stripe Checkout Session and redirects the user to Stripe's hosted payment page.
// actions/checkout.ts
'use server';
import { stripe } from '@/lib/stripe';
import { getSession } from '@/lib/auth';
import { redirect } from 'next/navigation';
export async function createCheckoutSession(priceId: string) {
const session = await getSession();
if (!session) redirect('/login');
const checkoutSession = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
customer_email: session.email,
success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing?canceled=true`,
metadata: {
userId: session.userId,
},
});
redirect(checkoutSession.url as string);
}
The metadata.userId here matters more than it looks. Stripe has no idea who your user is beyond what you attach in metadata, and the webhook handler needs this to know which account to update once payment completes.
// components/PricingCard.tsx
import { createCheckoutSession } from '@/actions/checkout';
export function PricingCard({ priceId, name, price }: { priceId: string; name: string; price: string }) {
return (
<form action={createCheckoutSession.bind(null, priceId)}>
<h3>{name}</h3>
<p>{price}/month</p>
<button type="submit">Subscribe</button>
</form>
);
}
3. Why Webhooks Are Not Optional
This is the part people try to skip, and it always causes problems. It is tempting to just update the database directly on the success_url page after redirect. Do not do this. The redirect happens in the user's browser, which means it can fail to load, get closed early, or never fire at all, and your database never finds out the payment actually succeeded.
Webhooks are Stripe calling your server directly, independent of whether the user's browser is even still open. This is the only reliable source of truth for what actually happened with a payment.
// app/api/webhooks/stripe/route.ts
import { stripe } from '@/lib/stripe';
import { connectDB } from '@/lib/db';
import User from '@/models/User';
import { headers } from 'next/headers';
import Stripe from 'stripe';
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET as string;
export async function POST(request: Request) {
const body = await request.text();
const headersList = await headers();
const signature = headersList.get('stripe-signature') as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
return new Response('Invalid signature', { status: 400 });
}
await connectDB();
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.metadata?.userId;
await User.findByIdAndUpdate(userId, {
stripeCustomerId: session.customer as string,
stripeSubscriptionId: session.subscription as string,
subscriptionStatus: 'active',
});
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await User.findOneAndUpdate(
{ stripeSubscriptionId: subscription.id },
{ subscriptionStatus: 'canceled' }
);
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription;
await User.findOneAndUpdate(
{ stripeSubscriptionId: subscription.id },
{ subscriptionStatus: subscription.status }
);
break;
}
}
return new Response('OK', { status: 200 });
}
stripe.webhooks.constructEvent verifies the request actually came from Stripe using the signature header, not from anyone who happened to guess your webhook URL. Skipping this check means anyone could send a fake checkout.session.completed payload and grant themselves a subscription for free.
4. Route Handlers Need Raw Body, Not Parsed JSON
This trips people up specifically with Next.js. Signature verification needs the exact raw request body, byte for byte, not a parsed and re-serialized version of it.
export async function POST(request: Request) {
const body = await request.text(); // raw text, not request.json()
// ...
}
Using request.json() here silently breaks signature verification, since JSON parsing and re-stringifying can change whitespace and key order in ways that make the signature no longer match.
5. Registering the Webhook Locally and in Production
For local development, the Stripe CLI forwards events to your local server:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
This prints a webhook signing secret specific to your CLI session, used as STRIPE_WEBHOOK_SECRET locally. In production, the webhook endpoint gets registered in the Stripe dashboard pointing at your real domain, with its own separate signing secret, different from the local one.
6. Checking Subscription Status Elsewhere in the App
Once the webhook keeps subscriptionStatus in sync, the rest of the app just reads it, no direct Stripe API calls needed on every page load.
// lib/queries/users.ts
export async function requireActiveSubscription() {
const session = await getSession();
if (!session) redirect('/login');
await connectDB();
const user = await User.findById(session.userId).lean();
if (user?.subscriptionStatus !== 'active') {
redirect('/pricing');
}
return user;
}
// app/(dashboard)/layout.tsx
import { requireActiveSubscription } from '@/lib/queries/users';
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
await requireActiveSubscription();
return <div>{children}</div>;
}
The database is the source of truth for whether a user has access, kept accurate by webhooks. The app never needs to ask Stripe directly just to render a page.
7. Handling Failed Payments
Subscriptions do not just cancel cleanly, cards expire, charges get declined. Stripe retries automatically, but your app needs to know when a subscription enters a past-due state:
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
await User.findOneAndUpdate(
{ stripeCustomerId: invoice.customer as string },
{ subscriptionStatus: 'past_due' }
);
break;
}
Whether past_due still grants access or restricts it is a product decision, not a technical one, but the webhook is what makes that state visible to your app in the first place.
Summary
| Piece | Handles |
|---|---|
| Checkout Session | Starting a subscription, redirecting to Stripe's hosted page |
| Webhook endpoint | The only reliable source of truth for payment events |
| Signature verification | Confirming events actually came from Stripe |
| Raw request body | Required for signature verification to succeed |
Database subscriptionStatus
|
What the rest of the app actually checks, kept in sync by webhooks |
invoice.payment_failed |
Catching failed renewals, not just initial signups |
The core lesson, learned the hard way on an early project: never trust the browser redirect as confirmation that a payment succeeded. The webhook is the only event that cannot be skipped, closed early, or spoofed without a valid signature, which makes it the only place worth writing real subscription logic.
I use this exact checkout and webhook setup across every SaaS project I build with subscription billing.
Get the templates: https://pixelanas.gumroad.com
Have you had a webhook edge case bite you in production? Drop it below ๐
Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751
Top comments (0)