Stripe Checkout is the fastest way to go from 'I need to accept money' to actually accepting money. Instead of building a payment form from scratch, handling card validation, PCI compliance nightmares, and supporting every card type under the sun, you redirect users to Stripe's hosted page and they handle all of it. You just listen for webhooks and update your database. Sounds simple, right? It mostly is — but there are enough sharp edges that we've seen (and hit) to warrant a proper guide.
We're going to build a full Stripe Checkout flow with Next.js App Router: creating checkout sessions, handling redirects, processing webhooks, and not leaking your Stripe secret key to the browser (which is more common than it should be).
The Architecture Before Any Code
Here's the mental model that makes everything else click. Stripe Checkout works in three phases: your server creates a checkout session and gets back a URL, you redirect the user to that URL, and Stripe calls your webhook after the payment succeeds. The redirect back to your site after payment is just for UX — it's not where you fulfill orders. That's what trips most people up the first time.
- User clicks 'Buy' → your server creates a Checkout Session via Stripe API
- User gets redirected to stripe.com/pay/... → they enter card details
- Stripe redirects back to your success_url → show a 'thanks' page
- Stripe sends checkout.session.completed webhook → THIS is where you fulfill
- Your webhook handler verifies the event signature → updates database
Never fulfill orders based on the success URL redirect. Users can navigate to that URL directly. Always wait for the webhook.
Setting Up Stripe in Next.js
Install the Stripe Node SDK and the Stripe.js client library. You only need the server SDK for server-side operations. Stripe.js is optional if you're using hosted Checkout — you don't need to load it for a simple redirect flow.
npm install stripe
# Only needed if you want Stripe Elements (custom payment UI)
# npm install @stripe/stripe-js @stripe/react-stripe-jsSet up your environment variables. The naming matters — NEXT_PUBLIC_ prefix means it gets bundled into client-side JavaScript. Your secret key should never have that prefix. Ever.
# .env.local
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
# This one is fine to be public — it's a publishable key
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...Create a singleton Stripe instance that you reuse across your app. Instantiating Stripe on every request is wasteful, and in serverless environments with connection reuse, it adds up.
// lib/stripe.ts
import Stripe from 'stripe';
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error('Missing STRIPE_SECRET_KEY environment variable');
}
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2024-06-20',
typescript: true,
});Creating the Checkout Session
Checkout sessions are created server-side — Route Handler or Server Action, your choice. We prefer Route Handlers for payment operations because they're explicit HTTP endpoints and easier to debug in the Stripe dashboard. Server Actions work too, but the error handling is slightly less predictable when Stripe throws.
// app/api/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { auth } from '@/lib/auth'; // your auth solution
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { priceId } = await req.json();
if (!priceId) {
return NextResponse.json({ error: 'Missing priceId' }, { status: 400 });
}
try {
const checkoutSession = await stripe.checkout.sessions.create({
mode: 'subscription', // or 'payment' for one-time
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1,
},
],
// Pass your user ID in metadata — you'll need it in the webhook
metadata: {
userId: session.user.id,
},
customer_email: session.user.email ?? undefined,
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?checkout=success`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
// Allow promotion codes if you use them
allow_promotion_codes: true,
});
return NextResponse.json({ url: checkoutSession.url });
} catch (error) {
console.error('Stripe checkout session error:', error);
return NextResponse.json(
{ error: 'Failed to create checkout session' },
{ status: 500 }
);
}
}Notice the metadata field with userId. This is critical — when Stripe calls your webhook, you need to know which user just paid. The checkout session object you get in the webhook will include this metadata. Forgetting this is the mistake that makes you dig through Stripe logs at midnight cross-referencing email addresses.
The Client-Side Redirect
On the frontend, you call your API endpoint and redirect to the URL Stripe returns. This is genuinely simple — no Stripe.js needed for hosted Checkout.
// components/checkout-button.tsx
'use client';
import { useState } from 'react';
interface CheckoutButtonProps {
priceId: string;
label?: string;
}
export function CheckoutButton({ priceId, label = 'Subscribe' }: CheckoutButtonProps) {
const [loading, setLoading] = useState(false);
const handleCheckout = async () => {
setLoading(true);
try {
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Something went wrong');
}
// Redirect to Stripe Checkout
window.location.href = data.url;
} catch (error) {
console.error('Checkout error:', error);
// Show toast/error to user
setLoading(false);
}
// Don't setLoading(false) on success — the page is navigating away
};
return (
<button
onClick={handleCheckout}
disabled={loading}
className="bg-blue-600 text-white px-6 py-3 rounded-lg disabled:opacity-50"
>
{loading ? 'Redirecting...' : label}
</button>
);
}One small detail: don't call setLoading(false) in the success path. The page is navigating away, so if you do, React will try to update state on an unmounted component and throw a warning. Not a bug, just noise.
Webhook Handler — The Most Important Part
This is where orders actually get fulfilled. Get this wrong and you either ship without collecting payment or collect payment without shipping. Both are bad. The webhook endpoint needs to verify the Stripe signature — this proves the request actually came from Stripe and not some random person who found your endpoint.
Next.js App Router has one footgun here: it parses the request body by default, which breaks Stripe's signature verification. You need the raw body bytes. Use req.text() or configure the route to skip body parsing.
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db'; // your database client
export async function POST(req: NextRequest) {
const body = await req.text(); // raw body — don't use req.json()
const signature = req.headers.get('stripe-signature');
if (!signature) {
return NextResponse.json({ error: 'Missing signature' }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error('Webhook signature verification failed:', err);
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
// Handle events
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
await handleCheckoutComplete(session);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionCancelled(subscription);
break;
}
// Always handle invoice.payment_failed for dunning
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
await handlePaymentFailed(invoice);
break;
}
default:
// Ignore unhandled events — don't error on them
break;
}
// Always return 200 to acknowledge receipt
return NextResponse.json({ received: true });
}
async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
const userId = session.metadata?.userId;
if (!userId) {
console.error('No userId in checkout session metadata:', session.id);
return;
}
await db.user.update({
where: { id: userId },
data: {
stripeCustomerId: session.customer as string,
subscriptionId: session.subscription as string,
plan: 'pro',
planExpiresAt: null, // null = active subscription
},
});
console.log(`Activated subscription for user ${userId}`);
}
async function handleSubscriptionCancelled(subscription: Stripe.Subscription) {
await db.user.updateMany({
where: { subscriptionId: subscription.id },
data: {
plan: 'free',
subscriptionId: null,
},
});
}
async function handlePaymentFailed(invoice: Stripe.Invoice) {
// Send email, mark account at risk, etc.
console.log(`Payment failed for invoice ${invoice.id}`);
}Stripe retries webhooks up to 3 days if you return non-2xx. Always return 200 even if you don't handle the event type — otherwise Stripe hammers your endpoint with retries for events you don't care about.
Testing Webhooks Locally
You can't test webhooks without the Stripe CLI — don't even try with ngrok tunnels, it's more pain than it's worth. Install the CLI, log in, and start forwarding.
# Install Stripe CLI (macOS)
brew install stripe/stripe-cli/stripe
# Login
stripe login
# Forward webhooks to your local server
# This gives you a webhook secret to use in .env.local
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# In another terminal, trigger test events
stripe trigger checkout.session.completedThe CLI will print a webhook signing secret that starts with whsec_. Use that as your STRIPE_WEBHOOK_SECRET in .env.local while testing. Your production secret is different — Stripe gives you a separate one when you register the webhook endpoint in the dashboard.
Common Gotchas We've Hit
Deploying to Vercel? Make sure your webhook URL in the Stripe dashboard points to your production domain, not a preview deployment. We once spent an embarrassing amount of time wondering why subscriptions weren't activating in production — turns out the webhook was still pointed at a dead preview URL from two deploys ago.
- Idempotency: Stripe can send the same webhook twice. Make your handlers idempotent — check if you've already processed the event before updating the database. Use the event.id or session.id as an idempotency key.
- Subscription vs payment mode: checkout sessions have a 'mode' field. 'subscription' for recurring, 'payment' for one-time. The metadata you get back differs slightly between them.
- Customer creation: if you pass customer_email instead of customer, Stripe creates a new customer object. If the user subscribes twice (cancelled and re-subscribed), you'll end up with duplicate customers. Better to create/retrieve a Stripe customer on your side and pass the customer ID.
- Webhook signature with middleware: if you have middleware that reads the request body (like logging middleware), it can break signature verification. Make sure your webhook route bypasses body-reading middleware.
- The success URL race condition: users sometimes land on the success page before the webhook fires. Don't show them their new plan tier from the database on that page — it'll still say 'free'. Either poll for the update, use optimistic UI, or just say 'your subscription is activating' without trying to read the DB.
Connecting to a Customer Portal
Once users are subscribed, they'll want to update their card, cancel, or upgrade. Don't build this yourself — Stripe has a Customer Portal. Create a portal session the same way you create a checkout session.
// app/api/portal/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { stripeCustomerId: true },
});
if (!user?.stripeCustomerId) {
return NextResponse.json(
{ error: 'No Stripe customer found' },
{ status: 404 }
);
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing`,
});
return NextResponse.json({ url: portalSession.url });
}You'll need to enable the Customer Portal in your Stripe dashboard and configure what actions users are allowed to take (cancel, update payment method, switch plans, etc.). It takes about 2 minutes to set up and saves you days of building subscription management UI.
If you'd rather not wire all of this up from scratch, the peal.dev Next.js templates include a working Stripe Checkout + Customer Portal integration with webhooks, a billing page, and the database schema already configured. Worth looking at if you want a reference implementation or just want to skip the boilerplate.
Production Checklist Before You Go Live
- Switch from sk_test_ to sk_live_ keys in your production environment variables
- Register your webhook endpoint in the Stripe dashboard (not just via CLI) and grab the production webhook secret
- Test the full flow in Stripe test mode with test card 4242 4242 4242 4242 before switching to live keys
- Add idempotency checks to your webhook handlers so duplicate events don't double-process
- Enable Stripe Radar (fraud protection) — it's on by default but make sure you haven't accidentally disabled it
- Set up webhook alerts in Stripe dashboard so you know if delivery fails
- Make sure your success_url and cancel_url use NEXT_PUBLIC_APP_URL, not hardcoded localhost
Test your webhook handler by manually triggering events with the Stripe CLI before going live. Specifically trigger checkout.session.completed and verify the user's plan actually updates in your database.
Stripe Checkout is genuinely one of the better third-party integrations you'll deal with as a developer. The API is consistent, the documentation is solid, and the hosted Checkout page converts well. The webhook-first fulfillment model feels weird at first but it's the right call — your fulfillment logic becomes reliable by default instead of dependent on a user not closing their tab at the wrong moment. Get the webhook handler right and everything else is straightforward.
