This guide walks you through integrating Stripe billing — including subscription plans, webhooks, and a customer self-serve portal — into a Next.js 15 app. Following all steps end-to-end takes roughly 90 minutes. By the end, you will have a production-ready billing system that handles plan upgrades, cancellations, and failed payments automatically.

What You'll Build

  • A Stripe Checkout flow that creates subscriptions from your Next.js frontend
  • A webhook handler that keeps your database in sync with Stripe billing events
  • A Stripe Customer Portal link so users can manage their own plan and payment method
  • Route-level plan gating that blocks unpaid users from protected pages
  • A working test environment using Stripe CLI and the Stripe test dashboard

Prerequisites

  • Node.js 20+ and pnpm 9 installed
  • A Next.js 15 project using the App Router (create one with pnpm create next-app@latest)
  • A free Stripe account — create one at stripe.com
  • Stripe CLI installed locally for webhook testing (brew install stripe/stripe-cli/stripe on macOS)
  • A database with a users table — this guide uses Supabase but the pattern applies to any provider
  • Basic familiarity with React Server Components and Next.js Route Handlers

Step 1: Install the Stripe SDK and Set Up Environment Variables

Start by adding the official Stripe Node.js library to your project. As of 2026, the current stable version is stripe@16.

pnpm add stripe @stripe/stripe-js

Next, add these keys to your .env.local file. Never commit this file to version control.

STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_APP_URL=http://localhost:3000

Find your API keys in the Stripe Dashboard under Developers → API keys. You will fill in STRIPE_WEBHOOK_SECRET in Step 4.

Why initialise Stripe as a singleton?

Instantiating the Stripe client inside every request creates unnecessary overhead. Create a shared instance instead.

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

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2025-10-28',
  typescript: true,
});

Expected result: Importing stripe from lib/stripe.ts throughout your app reuses a single connection. This also keeps your API version pinned so Stripe changelog updates don't silently break behaviour.

Step 2: Create Products and Prices in Stripe

Go to the Stripe Dashboard → Product catalogueAdd product. Create at least two plans — for example, Starter at $29/month and Pro at $79/month. Set the billing period to Monthly and the pricing model to Flat rate.

Copy the Price IDs (they look like price_1Abc...) into your app config.

// lib/plans.ts
export const PLANS = [
  {
    name: 'Starter',
    priceId: 'price_starter_monthly',
    monthlyPrice: 29,
  },
  {
    name: 'Pro',
    priceId: 'price_pro_monthly',
    monthlyPrice: 79,
  },
];

Common pitfall: Do not hardcode price amounts in your UI. Always derive them from Stripe's API or your config file. Prices change and your UI will drift out of sync.

Step 3: Build the Checkout Session Route Handler

Create a Route Handler that receives a Price ID and redirects the user to Stripe Checkout.

// app/api/stripe/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { getCurrentUser } from '@/lib/auth'; // your auth helper

export async function POST(req: NextRequest) {
  const { priceId } = await req.json();
  const user = await getCurrentUser();

  if (!user) {
    return NextResponse.json({ error: 'Unauthorised' }, { status: 401 });
  }

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

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

On your pricing page, call this endpoint on button click and redirect to session.url.

async function handleSubscribe(priceId: string) {
  const res = await fetch('/api/stripe/checkout', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ priceId }),
  });
  const { url } = await res.json();
  window.location.href = url;
}

Pro tip: Pass allow_promotion_codes: true to the session to let Stripe handle your discount codes automatically — no custom coupon UI required.

Step 4: Set Up Webhooks to Sync Billing State

Stripe sends events to your server whenever billing state changes. This is the most critical part of any billing integration. Skipping webhooks is the number-one reason billing state goes stale.

How do you test webhooks locally?

Use the Stripe CLI to forward events to your local server.

stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook

The CLI prints a webhook signing secret starting with whsec_. Paste that into STRIPE_WEBHOOK_SECRET in your .env.local.

What events should you handle?

Handle these four events as a minimum for a subscription product.

// app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { updateUserSubscription } from '@/lib/db'; // your DB helper

export async function POST(req: NextRequest) {
  const body = await req.text();
  const sig = req.headers.get('stripe-signature')!;

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

  const subscription = event.data.object as any;

  switch (event.type) {
    case 'customer.subscription.created':
    case 'customer.subscription.updated':
      await updateUserSubscription({
        stripeCustomerId: subscription.customer,
        status: subscription.status,
        priceId: subscription.items.data[0].price.id,
        currentPeriodEnd: subscription.current_period_end,
      });
      break;
    case 'customer.subscription.deleted':
      await updateUserSubscription({
        stripeCustomerId: subscription.customer,
        status: 'cancelled',
        priceId: null,
        currentPeriodEnd: null,
      });
      break;
    case 'invoice.payment_failed':
      // Notify the user via email here
      break;
  }

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

Critical pitfall: Always use req.text() — not req.json() — before passing the body to constructEvent. Parsing JSON first corrupts the raw body and signature verification will fail every time.

Step 5: Add the Customer Portal Route

Stripe's Customer Portal handles plan changes, payment method updates, and cancellations with zero custom UI. Enabling it takes two minutes.

In the Stripe Dashboard go to Settings → Billing → Customer portal and enable it. Then add this Route Handler.

// app/api/stripe/portal/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { getCurrentUser } from '@/lib/auth';
import { getUserStripeCustomerId } from '@/lib/db';

export async function POST(req: NextRequest) {
  const user = await getCurrentUser();
  if (!user) return NextResponse.json({ error: 'Unauthorised' }, { status: 401 });

  const customerId = await getUserStripeCustomerId(user.id);

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

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

Add a Manage Billing button in your account settings that calls this endpoint and redirects to portalSession.url. Users can now self-serve without contacting your support team — a major time saver for SMB SaaS products across Australia, Singapore, Canada, and the US.

Step 6: Gate Routes by Subscription Status

Protecting premium pages is where the database sync from Step 4 pays off. Read the user's subscription status from your database and block access if they are not on an active plan.