← Writing
Engineering·Mar 1, 2026·12 min read

Stripe + Next.js Integration: The Complete Production Guide (2026)

Most Stripe tutorials stop at "create a checkout session." That's maybe 20% of the work. Here's the full production picture — subscriptions, webhooks, customer portal, and middleware-based access control — from shipping real SaaS products.


TL;DR — What This Guide Covers

Stack: Stripe SDK + Next.js 15 App Router + TypeScript. No third-party wrappers.
Subscriptions: Create checkout sessions, handle plan selection, sync subscription state to your DB.
Webhooks: Full webhook handler with signature verification — the step most tutorials skip entirely.
Customer Portal: Let users manage billing, cancel, and upgrade themselves via Stripe-hosted portal.
Access control: Next.js middleware that reads subscription status from your DB to gate protected routes.

Why Stripe Integration Is Harder Than It Looks

The Stripe docs are excellent. The quickstart really does get you a working checkout in 15 minutes. And that's exactly the problem — it works well enough that you ship it, then discover six months later that your billing system is quietly broken in production.

The most common mistakes I see when auditing codebases:

  • Treating checkout as "just a link" — redirecting users to Stripe and assuming the redirect back means they paid. It doesn't. A user can close the browser mid-session. The checkout.session.completed webhook is the only reliable signal.
  • Missing webhook handling entirely — the redirect success URL is a UX convenience, not a payment confirmation. Relying on it means your access provisioning is race-prone and untrustworthy.
  • Not syncing subscription state to your DB — querying Stripe's API on every request to check subscription status is slow and fragile. Write subscription data to your own database on every webhook event.
  • Skipping webhook signature verification — without verifying the Stripe-Signature header, any attacker can POST fake events to your webhook endpoint and provision themselves free access.
  • Handling only checkout.session.completed — subscriptions update and cancel asynchronously. A user can dispute a charge, cancel from the Stripe portal, or have a card decline. None of these trigger a new checkout. They all arrive as subscription webhooks.

Most tutorials cover step one — checkout — and leave the rest as an exercise for the reader. This guide covers all of it.

The Architecture You Need

Before writing any code, understand the full data flow. Every part of this chain matters, and a missing link causes silent failures.

User clicks "Subscribe"
  → Your API creates a Stripe Checkout Session
  → User is redirected to Stripe-hosted checkout page
  → User completes payment on Stripe
  → Stripe sends checkout.session.completed webhook to your API
  → Your webhook handler verifies signature + writes to your DB
  → Stripe sends customer.subscription.* events on any status change
  → Your webhook handler keeps DB in sync on every event
  → Next.js middleware reads subscription status from DB
  → Protected routes (/dashboard, /app) are gated by middleware
  → User accesses protected content

The key insight: Stripe is the source of truth for payment state, but your database is the source of truth for your application. Every Stripe event must be durably written to your DB. Your application never queries Stripe at runtime — it reads from its own database, which webhooks keep in sync.

This separation is what makes billing reliable at scale. Stripe's API has SLAs, but adding a round-trip to every page load is unnecessary latency you control by maintaining local state.

Step 1: Setup

Install the Stripe Node SDK and the browser-side Stripe.js package:

npm install stripe @stripe/stripe-js

Add your environment variables. You need three:

# .env.local
STRIPE_SECRET_KEY=sk_live_...          # Never expose to client
STRIPE_WEBHOOK_SECRET=whsec_...        # From Stripe Dashboard → Webhooks
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...  # Safe for client bundle

Create a singleton Stripe instance. Instantiating it per-request is wasteful; the singleton pattern ensures one connection is reused across server-side calls:

// lib/stripe.ts
import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-11-20',
  typescript: true,
});

The typescript: true flag enables Stripe's TypeScript-aware types for all SDK responses. Always pin the apiVersion explicitly so Stripe API updates don't silently break your integration.

Step 2: Create Products and Price IDs

Create your products and pricing plans in the Stripe Dashboard (or via the API). Each plan gets a Price ID — a string that looks like price_1OAbc123XYZ. This ID is what you pass to the checkout session to tell Stripe what the user is buying.

Store price IDs as environment variables, not hardcoded strings. This lets you swap between test and live prices without code changes:

# .env.local
STRIPE_PRICE_BASE=price_1OAbc123XYZ_base
STRIPE_PRICE_PRO=price_1OAbc123XYZ_pro
STRIPE_PRICE_ENTERPRISE=price_1OAbc123XYZ_enterprise

Use test mode prices (price_test_...) in development and live prices in production. Your Stripe Dashboard will have separate test and live environments — always develop and test in test mode before going live.

Step 3: Checkout Session API Route

Create a server-side API route that generates a Stripe Checkout Session. This runs on your server — never call Stripe with your secret key from the client.

// app/api/checkout/route.ts
import { stripe } from '@/lib/stripe';
import { NextResponse } from 'next/server';

export async function POST(req: Request) {
  const { priceId, userId, email } = await req.json();

  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    payment_method_types: ['card'],
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
    customer_email: email,
    metadata: { userId },
  });

  return NextResponse.json({ url: session.url });
}

Two things to note here. First, metadata: { userId } — this is how you tie a Stripe payment back to a user in your system. Stripe passes this metadata through to every related webhook event. Without it, you receive a checkout.session.completed event with no way to know which user in your DB should get access. Always include your internal user ID in metadata.

Second, the success_url is purely cosmetic for UX. Do not use the ?success=true query param to provision access. Provision access in the webhook handler only.

On the client side, redirect the user to the returned session URL:

// In your pricing component (client component)
const res = await fetch('/api/checkout', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ priceId, userId, email }),
});
const { url } = await res.json();
window.location.href = url; // Redirect to Stripe

Step 4: Webhook Handler — The Critical Part

Webhooks are where most Stripe integrations break down. They are also the single most important part of your billing system. Get this wrong and you have a product that silently fails to provision or revoke access.

The three events you must handle for a subscription product:

  • checkout.session.completed — fires when a user completes checkout. This is when you first grant access.
  • customer.subscription.updated — fires when a subscription changes status: trial ending, payment retry, plan upgrade, reactivation. Keep your DB in sync.
  • customer.subscription.deleted — fires when a subscription is cancelled (immediately or at period end). Revoke access.

Here is the full webhook route with proper signature verification:

// app/api/webhooks/stripe/route.ts
import { stripe } from '@/lib/stripe';
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';

export async function POST(req: Request) {
  const body = await req.text(); // Raw body — CRITICAL (see mistake #1 below)
  const sig = headers().get('stripe-signature')!;

  let event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch {
    return NextResponse.json(
      { error: 'Invalid signature' },
      { status: 400 }
    );
  }

  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object;
      await syncSubscriptionToDb(session.subscription as string, session.metadata?.userId);
      break;
    }
    case 'customer.subscription.updated':
    case 'customer.subscription.deleted': {
      const subscription = event.data.object;
      await syncSubscriptionToDb(subscription.id, subscription.metadata?.userId);
      break;
    }
  }

  return NextResponse.json({ received: true });
}

// CRITICAL: Disable Next.js body parsing so we get the raw bytes
// Stripe signature verification requires the exact raw request body
export const config = { api: { bodyParser: false } };

The constructEvent call verifies that the webhook actually came from Stripe using your webhook secret. If verification fails — wrong secret, tampered body, replayed request — it throws and you return a 400. This is your security gate.

Step 5: Sync Subscription State to Your DB

After every relevant webhook, write the subscription's current state to your database. This is the function your webhook handler calls. The example below uses Firestore, but the pattern applies equally to any database.

// lib/syncSubscription.ts
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/firebase-admin'; // your DB client

export async function syncSubscriptionToDb(
  subscriptionId: string,
  userId?: string | null
) {
  const subscription = await stripe.subscriptions.retrieve(subscriptionId);

  // Resolve userId from subscription metadata if not passed directly
  const resolvedUserId = userId ?? subscription.metadata?.userId;
  if (!resolvedUserId) {
    console.error('syncSubscriptionToDb: no userId found', subscriptionId);
    return;
  }

  const subscriptionData = {
    subscriptionId: subscription.id,
    customerId: subscription.customer as string,
    status: subscription.status,               // 'active' | 'canceled' | 'past_due' | ...
    priceId: subscription.items.data[0].price.id,
    currentPeriodEnd: subscription.current_period_end,
    cancelAtPeriodEnd: subscription.cancel_at_period_end,
    updatedAt: Date.now(),
  };

  // Write to your DB — idempotent upsert
  await db.collection('users').doc(resolvedUserId).update({
    subscription: subscriptionData,
  });
}

The key fields to store: status, currentPeriodEnd, and cancelAtPeriodEnd. Your middleware and UI will read these directly. The status field is the primary gate — only 'active' and 'trialing' users should have access to paid features.

Note that this function fetches the full subscription from Stripe on each call. This is intentional — it ensures you always have the latest state, not a snapshot from the event payload which may have been queued.

Step 6: Protect Routes with Middleware

Next.js middleware runs before every request matching your matcher pattern. This is where you enforce subscription-based access control — read the user's subscription status from your DB (or a session cookie) and redirect unauthenticated or unpaid users before the page renders.

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { getSubscriptionStatus } from '@/lib/auth-helpers';

export async function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;

  // Get user session from your auth layer (Firebase, NextAuth, etc.)
  const userId = req.cookies.get('userId')?.value;

  if (!userId) {
    return NextResponse.redirect(new URL('/login', req.url));
  }

  // Check subscription status stored in your DB
  const status = await getSubscriptionStatus(userId);
  const hasAccess = status === 'active' || status === 'trialing';

  if (!hasAccess) {
    return NextResponse.redirect(new URL('/pricing?reason=subscription', req.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard/:path*', '/app/:path*'],
};

A critical performance note: middleware runs on every request. getSubscriptionStatus must be fast — read from a cache or session token, not a live DB call. One pattern is to store subscription status in a signed session cookie on login, and refresh it on webhook events. Another is to read from an edge-compatible KV store like Upstash Redis.

Never store raw subscription data in a JWT or unencrypted cookie — a user could manually set their subscription status to 'active'. Use a signed, server-verified session token, or always read from your database and trust only that.

Step 7: Customer Portal

Once users are subscribed, they need a way to manage their billing — upgrade plans, update payment methods, cancel, or view invoices. Stripe's Customer Portal handles all of this without you building any UI.

Create a portal session API route that redirects the user to Stripe's hosted portal:

// app/api/portal/route.ts
import { stripe } from '@/lib/stripe';
import { NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth-helpers';

export async function POST(req: Request) {
  const user = await getCurrentUser(req);
  if (!user?.customerId) {
    return NextResponse.json({ error: 'Not subscribed' }, { status: 400 });
  }

  const portalSession = await stripe.billingPortal.sessions.create({
    customer: user.customerId,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing`,
  });

  return NextResponse.json({ url: portalSession.url });
}

The Customer Portal must be configured in your Stripe Dashboard first — navigate to Settings → Billing → Customer Portal and enable the features you want users to access (cancel, upgrade, update payment method, view invoices). Any changes users make in the portal fire subscription webhooks that your handler already processes, keeping your DB in sync automatically.

The 3 Mistakes That Will Break Your Billing

1
Not using raw body for webhook signature verification
This is the most common production bug. Next.js (and most frameworks) parse the request body by default. Stripe's constructEvent needs the raw, unparsed bytes. If your body was JSON-parsed and re-serialized, verification fails. In App Router, use req.text() — not req.json(). The export const config = { api: { bodyParser: false } } export is also required.
2
Treating checkout.session.completed as the only event
Subscriptions have a lifecycle. A user who successfully subscribes today can have their card decline next month, cancel from the customer portal, or dispute a charge. All of these arrive as customer.subscription.updated or customer.subscription.deleted events — not as new checkout sessions. If your webhook only handles checkout.session.completed, cancelled and past-due users retain access indefinitely.
3
Not handling events idempotently
Stripe retries webhook delivery if your endpoint returns a non-2xx response or times out. Your handler may process the same event multiple times. Design your DB writes as upserts, not inserts. syncSubscriptionToDb should be safe to call 10 times with the same payload — the last write wins, and nothing breaks.

Testing Locally

Stripe provides a CLI tool that forwards live Stripe webhook events to your local development server. Install it from stripe.com/docs/stripe-cli, log in, and run:

stripe listen --forward-to localhost:3000/api/webhooks/stripe

This prints a webhook signing secret for your local session — paste it into your .env.local as STRIPE_WEBHOOK_SECRET. The CLI intercepts every event Stripe sends and forwards it to your local URL, including signature headers, so your verification code runs exactly as it would in production.

You can also trigger specific events manually:

stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted

For checkout testing, use Stripe's test card numbers. The most useful ones:

Card NumberBehavior
4242 4242 4242 4242Succeeds immediately. Use any future expiry and any 3-digit CVC.
4000 0000 0000 0002Always declined. Tests your decline handling.
4000 0025 0000 31553D Secure authentication required. Tests your auth flow.
4000 0000 0000 9995Insufficient funds decline. Tests payment failure copy.

For all test cards, use any future expiry date (12/34 works), any 3-digit CVC, and any billing zip. Stripe accepts these unconditionally in test mode.


Related articles

Abanoub Boctor
Abanoub Rodolf Boctor
Founder & CTO, ThynkQ · Mar 1, 2026
More articles →

Ready to build?

I turn ideas into shipped products. Fast.

Free 30-minute discovery call. Tell me what you're building — I'll tell you exactly how I'd approach it.

Book a free strategy call →

Related articles

How to Build a SaaS in 2 Weeks: A Real Case StudyHire an AI Engineer vs an AI Agency in 2026How Much Does It Cost to Build an AI SaaS MVP?
← Firebase vs SupabaseAll articles →