Most Stripe-in-Next.js tutorials get you to a working "Pay" button and stop there. The button works in test mode, the redirect goes through, the success page renders. Then you ship to production and discover the four things nobody mentioned: webhook signature verification, raw-body access in the App Router, fulfillment idempotency, and what happens when the user closes the tab on the success URL but the webhook fires anyway.
This walkthrough is the version we wish we'd had — a Next.js 15 App Router integration with Stripe Checkout that handles the realistic failure modes. It's the same pattern that runs our own storefront. You can drop the code straight in.
Architecture in one paragraph
Browser → POSTs to your /api/checkout/[slug] route → server creates a Stripe Checkout Session → returns the session URL → browser redirects to Stripe-hosted page → user pays → Stripe redirects back to your /thanks page AND fires a checkout.session.completed webhook to your /api/stripe/webhook route → webhook fulfills the order (sends license email, provisions the account, marks the order paid). The success page is only for UX. Fulfillment happens in the webhook so it survives a closed tab.
Setup
You need: a Stripe account in test mode, a Next.js 15 project with the App Router, and Node 20+.
npm install stripe
npm install -D @types/node
Create your .env.local (never commit this — add to .gitignore):
STRIPE_SECRET_KEY=sk_test_…
STRIPE_WEBHOOK_SECRET=whsec_…
NEXT_PUBLIC_SITE_URL=http://localhost:3000
The publishable key (pk_…) is not needed for hosted Checkout. You only need it if you're doing Stripe Elements or the embedded Checkout component. For the redirect flow, the secret key alone is enough.
Create a single Stripe client module so you can configure it once:
// lib/stripe.ts
import Stripe from 'stripe';
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error('STRIPE_SECRET_KEY is missing');
}
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2025-02-24.acacia',
});
Pin the API version. The Stripe SDK targets the latest, but pinning means a Stripe-side breaking change won't ship to your production at 3am because npm pulled a minor.
Step 1: the checkout session route
app/api/checkout/[slug]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { getProduct } from '@/lib/catalog';
export const runtime = 'nodejs'; // not edge — Stripe SDK needs Node
export async function POST(
req: NextRequest,
ctx: { params: Promise<{ slug: string }> }
) {
const { slug } = await ctx.params;
const product = await getProduct(slug);
if (!product) {
return NextResponse.json({ error: 'Unknown product' }, { status: 404 });
}
const origin = req.headers.get('origin') ?? process.env.NEXT_PUBLIC_SITE_URL;
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{
price_data: {
currency: 'usd',
product_data: {
name: product.name,
description: product.blurb,
},
unit_amount: product.priceUsd * 100, // Stripe wants cents
},
quantity: 1,
}],
success_url: `${origin}/thanks?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/products/${slug}`,
metadata: {
slug,
product_name: product.name,
},
customer_creation: 'always',
automatic_tax: { enabled: false }, // see notes
});
return NextResponse.json({ url: session.url });
}
Three things worth noting in this snippet that most tutorials skip:
runtime = 'nodejs'. The Stripe SDK uses Node primitives that aren't in the Edge runtime. You will see opaque errors if you don't set this.metadata.slugis the only thing the webhook needs to know what to fulfill. Stripe's session won't carry your internal product ID otherwise.customer_creation: 'always'guarantees a Customer object exists after payment, which you'll want for refunds, license keys, and downstream subscriptions later.
The price_data approach (creating prices inline) is fine for a small catalog. For 50+ SKUs, create products and prices once in the Stripe dashboard or via the API and reference them by ID — the dashboard becomes your catalog source of truth.
Step 2: the buy button
This is the only client-side piece. It POSTs to your route, receives the URL, redirects.
'use client';
export function BuyButton({ slug, label }: { slug: string; label: string }) {
const onClick = async () => {
const r = await fetch(`/api/checkout/${slug}`, { method: 'POST' });
if (!r.ok) {
alert('Could not start checkout.');
return;
}
const { url } = await r.json();
window.location.href = url;
};
return <button onClick={onClick} className="btn btn-primary">{label}</button>;
}
Don't render any "loading" indicator longer than the redirect itself — the Stripe-hosted page loads in 200-400ms in most regions. A spinner that lingers after the click usually means you're double-firing the click handler.
Step 3: the webhook (the part that actually matters)
app/api/stripe/webhook/route.ts
The webhook is where the real money flows. It must do three things correctly:
- Verify the
Stripe-Signatureheader against the raw request body. - Be idempotent — Stripe will retry on any non-2xx response, and may legitimately deliver the same event twice.
- Respond fast. Stripe expects a 2xx within ~30 seconds; long fulfillment work belongs in a queue.
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { fulfillOrder, isAlreadyFulfilled } from '@/lib/fulfillment';
export const runtime = 'nodejs';
export async function POST(req: NextRequest) {
const sig = req.headers.get('stripe-signature');
if (!sig) return new NextResponse('No signature', { status: 400 });
// CRITICAL: get the raw body, not parsed JSON.
const body = await req.text();
let event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
const msg = err instanceof Error ? err.message : 'unknown';
return new NextResponse(`Webhook error: ${msg}`, { status: 400 });
}
// Idempotency: skip if we've already processed this event.
if (await isAlreadyFulfilled(event.id)) {
return NextResponse.json({ received: true, deduped: true });
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
await fulfillOrder({
eventId: event.id,
sessionId: session.id,
slug: session.metadata?.slug ?? '',
customerEmail: session.customer_details?.email ?? null,
amountTotal: session.amount_total ?? 0,
});
}
return NextResponse.json({ received: true });
}
The five things that bite in production
1. The App Router serves a parsed body by default
Stripe signs the raw bytes of the request. req.json() parses then re-stringifies — the bytes won't match, signature verification fails, and you'll spend an afternoon staring at "No signatures found matching the expected signature" errors. Use await req.text() and pass that string to constructEvent. The App Router does not need the old config.api.bodyParser = false incantation.
2. The webhook secret in dev is different from prod
For local development, install the Stripe CLI and forward events:
brew install stripe/stripe-cli/stripe
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
The CLI prints a whsec_… on first run — that goes in your .env.local. Production gets a different secret, generated when you register your live endpoint URL in the Stripe dashboard. Mixing them up is the most common reason a working dev integration fails the moment you deploy.
3. Idempotency is not optional
Per the Stripe webhook docs, Stripe may deliver the same event more than once. If your webhook charges a credit, sends an email, or creates a license key, doing it twice creates a real customer-facing bug. The minimum implementation:
// lib/fulfillment.ts (Postgres-flavoured pseudocode)
export async function isAlreadyFulfilled(eventId: string) {
const row = await db.query(
'SELECT 1 FROM webhook_events WHERE event_id = $1',
[eventId]
);
return row.rowCount > 0;
}
export async function fulfillOrder(input: FulfillInput) {
await db.transaction(async (tx) => {
// record the event first; UNIQUE constraint blocks duplicates
await tx.query(
'INSERT INTO webhook_events (event_id, processed_at) VALUES ($1, NOW())',
[input.eventId]
);
// now do the actual work
await sendLicenseEmail(input);
await markOrderPaid(input);
});
}
If the second insert hits the UNIQUE constraint, the transaction rolls back and the second invocation is a no-op. Cheaper than building a distributed lock.
4. The success page can never be the source of truth
If the user closes the browser tab during the redirect back from Stripe, your /thanks page never loads — but the payment still went through and the webhook still fires. Fulfillment must happen server-side in the webhook. The /thanks page is purely UX:
// app/thanks/page.tsx
import { stripe } from '@/lib/stripe';
export default async function Thanks({
searchParams,
}: {
searchParams: Promise<{ session_id?: string }>;
}) {
const { session_id } = await searchParams;
if (!session_id) return <p>Missing session.</p>;
const session = await stripe.checkout.sessions.retrieve(session_id);
if (session.payment_status !== 'paid') {
return <p>Payment is processing — check your email shortly.</p>;
}
return (
<div>
<h1>Thanks for your order</h1>
<p>A receipt is on the way to {session.customer_details?.email}</p>
</div>
);
}
Note this fetches from Stripe directly rather than your DB. The webhook may not have fired yet by the time the user lands here — Stripe redirects immediately on payment, the webhook is a separate async event. Stripe's record of the session is the truth in that race.
5. Tax, currency, and the platform fee
Three real-world adjustments to make before going live:
- Sales tax. If you sell to US customers, set
automatic_tax: { enabled: true }and configure tax registration in the Stripe dashboard. Selling internationally without this can violate VAT rules in the EU and UK and create personal liability. - Currency. Stripe shows pricing in the customer's local currency only if you create prices in those currencies. Single-currency stores are fine for B2B SaaS — convert at the customer's bank.
- Stripe's fee. The default US rate is 2.9% + $0.30 per successful transaction (Stripe pricing page). On a $19 product, that's roughly $0.85 — a real margin hit on low-ticket items. Worth modeling before you set list prices.
Testing the full loop locally
Three terminals, one workflow:
# terminal 1
npm run dev
# terminal 2: forward webhooks
stripe listen --forward-to localhost:3000/api/stripe/webhook
# terminal 3: trigger a test purchase
stripe trigger checkout.session.completed
Or use the test card numbers against your real flow:
4242 4242 4242 4242— succeeds4000 0000 0000 9995— declines (insufficient funds)4000 0025 0000 3155— requires 3D Secure
Test the 3D Secure card before launch. The redirect flow works the same, but if you're rendering anything custom on top of the session URL it can break in subtle ways.
Going live
Five steps:
- Toggle to live mode in the Stripe dashboard. Re-create products, taxes, and shipping.
- Replace
STRIPE_SECRET_KEYwith yoursk_live_…key in your hosting platform's env vars. - Register the production webhook URL (
https://yoursite.com/api/stripe/webhook), selectcheckout.session.completedat minimum, copy the newwhsec_…into your env. - Run a real $1 purchase end-to-end. Verify the receipt arrives, your DB has the order, the webhook log shows 200.
- Refund the test charge in the dashboard. Verify your refund webhook handler (if you have one) does the right thing.
If you skip step 4 and go straight to charging real money, you will discover a misconfigured production environment the slow way. Spend the $1 first.
You should buy if…
The above is a complete-enough integration to ship a real digital product. If you want a working end-to-end store rather than just the checkout primitive, three of our products are paired with this pattern:
- Orbit ($199) — a SaaS landing template wired for a Stripe-direct checkout flow. Drop your
STRIPE_SECRET_KEYand ship. - Horizon ($49) — a 5-page startup landing template with the same checkout button pattern.
- Ship Your First Side Project in 30 Days ($19) — the day-by-day plan for going from blank repo to live, paid-for product. Includes a chapter on payments setup.
If you're choosing between Stripe direct vs Gumroad for a low-ticket digital product, we covered the math in Gumroad vs Stripe Direct Checkout — the crossover where Stripe direct saves more than it costs is roughly $1,000-$3,000/month in revenue.
Related reading: Gumroad vs Stripe Direct Checkout · The 2026 SaaS landing page checklist · Deploy a static site to Cloudflare Pages