How to Integrate Stripe Checkout in a Next.js App (Step-by-Step Guide)
A practical step-by-step guide to integrating Stripe Checkout into a Next.js app, covering session creation, webhooks, and production readiness.
Most teams reach for Stripe Checkout because it is fast to ship and offloads PCI liability. But "fast" turns into a weekend of debugging when session creation, redirects, and webhook fulfillment are not wired together correctly. This guide walks through a complete, production-ready Stripe Checkout integration in Next.js — from API route to order confirmation.
Setting Up Stripe Checkout in a Next.js Project
1. Install dependencies and configure keys
npm install stripe @stripe/stripe-js
Store your keys in environment variables. Never commit secret keys to source control.
STRIPE_SECRET_KEY— used on the server onlyNEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY— safe to expose in browser bundlesSTRIPE_WEBHOOK_SECRET— generated when you register your webhook endpoint
2. Create a Checkout Session in an API route
Stripe Checkout works by creating a session on your server, then redirecting the customer to Stripe's hosted page. In Next.js App Router:
// app/api/checkout/route.ts
import Stripe from "stripe";
import { NextResponse } from "next/server";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const { priceId, customerId } = await req.json();
const session = await stripe.checkout.sessions.create({
mode: "subscription", // or "payment" for one-time
customer: customerId, // attach to an existing customer record
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/pricing`,
});
return NextResponse.json({ url: session.url });
}
On the client, call this route and redirect:
const res = await fetch("/api/checkout", { method: "POST", body: JSON.stringify({ priceId }) });
const { url } = await res.json();
window.location.href = url;
3. Never fulfill on the redirect — use webhooks
The success_url redirect is unreliable. A dropped network connection after payment means your success page never loads, but the customer was still charged. Always provision access through the checkout.session.completed webhook event.
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { headers } from "next/headers";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text(); // must be raw bytes
const sig = headers().get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response("Signature verification failed", { status: 400 });
}
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
await provisionAccess(session.customer as string, session.subscription as string);
}
return new Response("ok", { status: 200 });
}
Return 200 immediately. If your fulfillment logic throws, Stripe will retry for up to 72 hours.
Handling the Customer Portal for Subscription Management
After checkout, customers need to cancel, upgrade, or update their payment method. Stripe's Customer Portal handles all of this without any additional UI work.
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_BASE_URL}/dashboard`,
});
// redirect to portalSession.url
Enable the portal in your Stripe Dashboard under Billing > Customer portal.
Testing Before Going Live
Use Stripe's test card 4242 4242 4242 4242 with any future expiry and any CVC for a successful payment. Test declined cards with 4000 0000 0000 0002. Run your webhook handler locally using the Stripe CLI:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
This streams real test events to your local server, so you can verify fulfillment logic end-to-end before touching production.
Common Pitfalls
- Parsing the body before signature verification — Express middleware that parses JSON will corrupt the raw bytes Stripe uses to compute the signature. In Next.js App Router,
req.text()gives you the raw body directly. - Reusing a Checkout Session URL — Sessions expire after 24 hours. Always create a fresh session per checkout attempt.
- Missing
customeron the session — Attach a Stripe Customer ID so the portal and subscription lookups work without extra joins. - No idempotency on session creation — Pass an
idempotencyKeyheader if you retry session creation, to avoid duplicate sessions.
What Comes Next
A working Stripe Checkout integration is not the end. You will need to handle subscription lifecycle events — customer.subscription.updated, customer.subscription.deleted, invoice.payment_failed — to keep your database in sync with Stripe's state. Plan the event model before you ship.
If you need a Stripe integration built correctly from day one — with webhooks, portal, subscription lifecycle, and multi-environment setup — Start a build with Clixo.