WritingHow to Integrate Stripe Checkout in a Next.js App (Step-by-Step Guide) — Clixo
4 min readstripe, nextjs, payments, stripe-checkout, integration

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 only
  • NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY — safe to expose in browser bundles
  • STRIPE_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 customer on the session — Attach a Stripe Customer ID so the portal and subscription lookups work without extra joins.
  • No idempotency on session creation — Pass an idempotencyKey header 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.