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.
Stripe Checkout provides a hosted payment page. This example connects session creation, the client redirect, and webhook handling in Next.js. Treat it as a starting point: authentication, fulfillment rules, idempotency, and payment-method behavior need validation for your product.
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.
For help reviewing a Checkout integration and its billing events, contact LinkIT.