DevStacked
Jun 11, 202614 min read

Stripe Subscriptions in Next.js 16: Complete SaaS Billing Guide (2026)

Most developers can wire up a one-time payment in an afternoon. Subscriptions? That's where things get confusing fast.

What happens when a user's card expires? What if they cancel mid-cycle? How do you know their subscription is still active before letting them into your dashboard? These aren't edge cases — they're everyday realities of any SaaS product.

The good news is Stripe handles almost all of this complexity for you. Your job is knowing how to plug it in correctly. In this guide, you'll build a complete monthly subscription billing flow in Next.js 16 using Stripe Checkout Sessions — the fastest and most reliable way to add subscriptions to your SaaS.

By the end, you'll have:

  • A pricing page that redirects users to Stripe Checkout
  • Webhook handling that activates subscriptions in your database
  • A customer portal where users can manage or cancel their plan
  • A solid understanding of how subscription lifecycle events work

Why Subscriptions Are Different From One-Time Payments

A one-time payment is simple: user pays → you deliver → done.

A subscription is an ongoing relationship. Stripe needs to charge the user every month, handle failed payments, retry logic, and tell your app what's happening at every step. Your backend needs to listen to those updates via webhooks and keep your database in sync.

If you skip webhooks and just trust the frontend redirect, you're asking for trouble. The user's payment could fail silently, or they could cancel their plan and still access your app for months.


What We're Building

A minimal SaaS billing setup with two plans:

PlanPrice
Starter$29/month
Pro$79/month

The flow works like this:

User clicks "Subscribe"
        
Server Action creates Checkout Session
        
User redirected to Stripe hosted checkout
        
Payment completed  user redirected back
        
Stripe fires webhook  your app activates subscription
        
User can manage billing via Customer Portal

Prerequisites

Make sure you have:

  • Node.js 20+ installed
  • A Stripe account (free to create)
  • Basic familiarity with Next.js App Router

We'll be using:

  • Next.js 16 with App Router
  • TypeScript strict mode
  • Tailwind CSS for any UI
  • React Compiler (no manual useMemo/useCallback needed)

Step 1: Create a Next.js Project

npx create-next-app@latest stripe-subscriptions

Choose:

  • TypeScript ✅
  • Tailwind CSS ✅
  • App Router ✅
  • React Compiler ✅
  • src/ directory ✅

Move into the project:

cd stripe-subscriptions

Step 2: Install Stripe

npm install stripe

This is the official Stripe Node.js SDK. We'll use it on the server only — it should never touch the browser.


Step 3: Set Up Stripe Server Client

Create a reusable Stripe instance so you don't initialize it in every file.

// src/lib/stripe.ts
import Stripe from "stripe";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2026-05-27.dahlia",
});

⚠️ Common Mistake: Never import this file into a Client Component. The STRIPE_SECRET_KEY must stay server-side only.


Step 4: Create Products and Prices in Stripe

Before writing code, set up your products in the Stripe dashboard.

  1. Go to dashboard.stripe.com
  2. Enable Test Mode (toggle in the top right)
  3. Copy Publishable & Secret keys
  4. Navigate to Product Catalog → Create Product
  5. Create two products:

Starter Plan

  • Name: Starter
  • Price: $29.00 USD
  • Billing: Recurring Monthly

Pro Plan

  • Name: Pro
  • Price: $79.00 USD
  • Billing: Recurring Monthly

After saving, click into each product and copy the Price ID (starts with price_). You'll need these next.


Step 5: Configure Environment Variables

Create .env.local in your project root:

# .env.local
STRIPE_SECRET_KEY=sk_test_xxxx
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxxx
NEXT_PUBLIC_APP_URL=http://localhost:3000
STRIPE_WEBHOOK_SECRET=whsec_xxxx

# Price IDs
STRIPE_STARTER_PRICE_ID=price_xxxx
STRIPE_PRO_PRICE_ID=price_xxxx

⚠️ Important: Add .env.local to .gitignore immediately. Never commit secret keys to version control.

Variables starting with NEXT_PUBLIC_ are safe to expose to the browser. Everything else is server-only.


Step 6: Define Plan Constants

Keep your plan data in one place so you never hardcode price IDs across multiple files.

// src/lib/plans.ts
export const PLANS = {
  starter: {
    id: "starter",
    name: "Starter",
    price: 29,
    priceId: process.env.STRIPE_STARTER_PRICE_ID!,
    features: [
      "Up to 5 projects",
      "10GB storage",
      "Email support",
    ],
  },
  pro: {
    id: "pro",
    name: "Pro",
    price: 79,
    priceId: process.env.STRIPE_PRO_PRICE_ID!,
    features: [
      "Unlimited projects",
      "100GB storage",
      "Priority support",
      "Advanced analytics",
    ],
  },
} as const;

export type PlanId = keyof typeof PLANS;

💡 Tip: Using as const gives you full TypeScript autocompletion on plan IDs throughout your app — no magic strings.


Step 7: Create the Checkout Server Action

A Server Action is an async function that runs on the server. We'll use one to create the Stripe Checkout Session — this keeps your secret key safe and your code clean.

// src/actions/create-checkout.ts
"use server";

import { redirect } from "next/navigation";
import { stripe } from "@/lib/stripe";
import { PLANS, PlanId } from "@/lib/plans";

export async function createCheckout(
  prev: unknown,
  formData: FormData
) {
  let url;
  try {
    const planId = formData.get("planId") as PlanId;

    if (!planId || !(planId in PLANS)) {
      return { error: "Invalid plan selected" };
    }

    const plan = PLANS[planId];

    const session = await stripe.checkout.sessions.create({
      mode: "subscription",           // ← this is the key difference from one-time payments
      line_items: [
        {
          price: plan.priceId,
          quantity: 1,
        },
      ],
      success_url: `${process.env.NEXT_PUBLIC_APP_URL}/`,
      cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
      metadata: {
        planId,
      },
      // Collect billing address for invoices
      billing_address_collection: "auto",
      // Allows users to apply promo codes at checkout
      allow_promotion_codes: true,
    });

    if (!session.url) {
      return { error: "Failed to create checkout session" };
    }
    url = session.url;
  } catch (err) {
    return {
      error: err instanceof Error ? err.message : "Something went wrong",
    };
  }
  if (url) {
    redirect(url);
  }
}

What's happening here:

  • mode: "subscription" tells Stripe this is a recurring payment, not a one-time charge
  • line_items specifies which price the user is subscribing to
  • success_url / cancel_url control where Stripe sends the user after checkout
  • metadata.planId lets you identify which plan was purchased inside your webhook later
  • In subscription mode, Stripe automatically creates a Customer object — no extra config needed

Step 8: Build the Pricing Page

// src/app/pricing/page.tsx
"use client";

import { useActionState } from "react";
import { createCheckout } from "@/actions/create-checkout";
import { PLANS } from "@/lib/plans";

export default function PricingPage() {
  const [state, action, isPending] = useActionState(createCheckout, {
    error: "",
  });

  return (
    <main className="min-h-screen py-20 px-4">
      <div className="max-w-4xl mx-auto text-center mb-12">
        <h1 className="text-4xl font-bold tracking-tight">
          Simple, transparent pricing
        </h1>
        <p className="mt-4 text-muted-foreground text-lg">
          Start free. Upgrade when you're ready.
        </p>
      </div>

      <div className="grid md:grid-cols-2 gap-6 max-w-3xl mx-auto">
        {Object.values(PLANS).map((plan) => (
          <div
            key={plan.id}
            className="rounded-2xl border p-8 flex flex-col gap-6"
          >
            <div>
              <h2 className="text-xl font-semibold">{plan.name}</h2>
              <div className="mt-2">
                <span className="text-4xl font-bold">${plan.price}</span>
                <span className="text-muted-foreground"> / month</span>
              </div>
            </div>

            <ul className="space-y-2 flex-1">
              {plan.features.map((feature) => (
                <li key={feature} className="flex items-center gap-2 text-sm">
                  <span className="text-green-500"></span>
                  {feature}
                </li>
              ))}
            </ul>

            <form action={action}>
              <input type="hidden" name="planId" value={plan.id} />
              <button
                type="submit"
                disabled={isPending}
                className="w-full rounded-xl bg-primary text-primary-foreground py-3 font-medium disabled:opacity-50 transition-opacity"
              >
                {`Subscribe to ${plan.name}`}
              </button>
            </form>
          </div>
        ))}
      </div>

      {state?.error && (
        <p className="text-red-500 text-center mt-6">{state.error}</p>
      )}
    </main>
  );
}

The hidden planId input is how the Server Action knows which plan the user clicked. The name attribute on form inputs is what formData.get("planId") reads.


Step 9: Handle Subscription Events with Webhooks

This is the most important part. When a user pays (or cancels, or their card fails), Stripe sends a POST request to your webhook endpoint. Your app listens, updates the database, and keeps subscription state accurate.

⚠️ Never trust the frontend redirect alone. A user could close the tab after payment, the redirect could fail, or someone could fake the success URL. Webhooks are your source of truth.

Create the Webhook Route

// src/app/api/webhook/route.ts
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import Stripe from "stripe";
import { stripe } from "@/lib/stripe";

const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(req: Request) {
  try {
    const payload = await req.text();
    const sig = (await headers()).get("stripe-signature")!;

    let event: Stripe.Event;

    // Verify the webhook actually came from Stripe
    try {
      event = stripe.webhooks.constructEvent(payload, sig, endpointSecret);
    } catch (err) {
      const message = err instanceof Error ? err.message : "Unknown error";
      return NextResponse.json(
        { error: `Webhook signature failed: ${message}` },
        { status: 400 }
      );
    }

    switch (event.type) {
      // Fired when a new subscription is created and first payment succeeds
      case "checkout.session.completed": {
        const session = event.data.object as Stripe.Checkout.Session;

        if (session.mode !== "subscription") break;

        const customerId = session.customer as string;
        const subscriptionId = session.subscription as string;
        const planId = session.metadata?.planId;

        console.log("New subscription:", {
          customerId,
          subscriptionId,
          planId,
        });

        // TODO: Save to your database
        // await db.user.update({ stripeCustomerId: customerId, subscriptionId, plan: planId, status: "active" })

        break;
      }

      // Fired on every successful recurring charge
      case "invoice.paid": {
        const invoice = event.data.object as Stripe.Invoice;
        const subscriptionId = invoice.subscription as string;

        console.log("Invoice paid — subscription renewed:", subscriptionId);

        // TODO: Mark subscription as active / extend access period
        // await db.subscription.update({ id: subscriptionId, status: "active" })

        break;
      }

      // Fired when a payment fails (expired card, insufficient funds, etc.)
      case "invoice.payment_failed": {
        const invoice = event.data.object as Stripe.Invoice;
        const subscriptionId = invoice.subscription as string;

        console.log("Payment failed for subscription:", subscriptionId);

        // TODO: Notify user and mark subscription as past_due
        // await db.subscription.update({ id: subscriptionId, status: "past_due" })
        // await sendPaymentFailedEmail(invoice.customer_email)

        break;
      }

      // Fired when user cancels or subscription expires
      case "customer.subscription.deleted": {
        const subscription = event.data.object as Stripe.Subscription;

        console.log("Subscription cancelled:", subscription.id);

        // TODO: Revoke access
        // await db.subscription.update({ id: subscription.id, status: "cancelled" })

        break;
      }

      // Fired when plan changes (upgrade/downgrade)
      case "customer.subscription.updated": {
        const subscription = event.data.object as Stripe.Subscription;

        console.log("Subscription updated:", subscription.id, subscription.status);

        // TODO: Sync the new plan and status to your database

        break;
      }

      default:
        // Ignore unhandled event types
        break;
    }

    // Always return 200 so Stripe knows the event was received
    return NextResponse.json({ received: true }, { status: 200 });

  } catch (err) {
    const error = err instanceof Error ? err.message : "Unexpected error";
    console.error("Webhook error:", error);
    return NextResponse.json({ error }, { status: 500 });
  }
}

Why req.text() instead of req.json()?

Stripe's signature verification requires the raw request body as a string. If you parse it with .json() first, the signature check will fail. Always use .text() in webhook handlers.

Why return 200 even for events you don't handle?

Stripe retries events that don't get a 2xx response. If you return a 4xx for an unknown event type, Stripe will keep retrying it forever. Return 200 for everything that isn't a genuine error.


Step 10: Test Webhooks Locally with Stripe CLI

Stripe can't reach localhost, so you need the Stripe CLI to forward events to your dev server.

Install the Stripe CLI: docs.stripe.com/stripe-cli

Then run:

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

This prints a webhook signing secret — copy it into your .env.local as STRIPE_WEBHOOK_SECRET.

In a separate terminal, start your dev server:

npm run dev

Now when you complete a test checkout, you'll see the events streaming in your CLI terminal.


Step 11: Add a Customer Portal

Once users are subscribed, they'll need a way to update their card, change plans, or cancel. Stripe's Customer Portal handles all of this for you.

Enable the Portal in Stripe Dashboard

  1. Go to Settings → Billing → Customer Portal
  2. Enable it and configure:
    • Allow plan changes ✅
    • Allow cancellations ✅
    • Allowed plans: select your Starter and Pro prices
  3. Save settings

Create the Portal Server Action

// src/actions/create-portal.ts
"use server";

import { redirect } from "next/navigation";
import { stripe } from "@/lib/stripe";

export async function createPortalSession(customerId: string) {
  if (!customerId) {
    throw new Error("Customer ID is required");
  }

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

  redirect(session.url);
}

Add a "Manage Billing" Button to Your Home

// src/app/page.tsx
import { createPortalSession } from "@/actions/create-portal";

// In a real app, fetch the stripeCustomerId from your database
const DEMO_CUSTOMER_ID = "cus_xxxx"; // replace with real DB lookup

export default function Page() {
    const action = createPortalSession.bind(null, DEMO_CUSTOMER_ID)
    return (
        <main className="p-8">
            <h1 className="text-2xl font-bold mb-6">Dashboard</h1>

            <form
                action={action}
            >
                <button
                    type="submit"
                    className="w-fit rounded-xl border p-2 text-sm font-medium hover:bg-muted transition-colors"
                >
                    Manage Billing
                </button>
            </form>
        </main>
    );
}

When the user clicks "Manage Billing", they're taken to Stripe's hosted portal. They can update payment methods, switch plans, or cancel — and Stripe fires webhooks for every action so your database stays in sync automatically.


Step 12: Deploy and Configure Production Webhooks

Before going live, update your environment variables in Vercel (or your hosting provider):

  1. Add all your .env.local variables to your Vercel project settings
  2. Switch to live Stripe keys (sk_live_, pk_live_)
  3. Create a new webhook in the Stripe Dashboard:
    • Endpoint URL: https://yourdomain.com/api/webhook
    • Events to listen for:
      • checkout.session.completed
      • invoice.paid
      • invoice.payment_failed
      • customer.subscription.deleted
      • customer.subscription.updated
  4. Copy the signing secret and add it as STRIPE_WEBHOOK_SECRET in Vercel

Preventing Duplicate Processing

Stripe may send the same webhook event more than once if your server is slow or returns an error. Before processing any event, check if you've already handled it:

// Inside your webhook handler, before processing:
const eventId = event.id;

// Check if already processed
const existing = await db.webhookEvent.findUnique({ where: { stripeEventId: eventId } });
if (existing) {
  return NextResponse.json({ received: true }, { status: 200 });
}

// Store the event ID first, then process
await db.webhookEvent.create({ data: { stripeEventId: eventId } });
// ... rest of processing

This is called idempotency — handling the same event twice has the same result as handling it once.


Frequently Asked Questions

How do I know if a user's subscription is active?

Store the subscription status in your database when the webhook fires. On every protected page load, query your database for the user's subscription status. Don't call the Stripe API on every request — that's slow and wastes API calls. Keep your DB as the source of truth, synced by webhooks.

What's the difference between checkout.session.completed and invoice.paid?

checkout.session.completed fires once when a user completes the initial checkout. invoice.paid fires every time a subscription renews (monthly/yearly). You need to listen to both — one to activate the account, one to keep it active.

Can I offer a free trial?

Yes. Add subscription_data: { trial_period_days: 14 } to your stripe.checkout.sessions.create() call. Stripe won't charge the user until the trial ends, and it'll fire the appropriate webhook events when it does.

💡 Tip: Set payment_method_collection: "always" alongside a trial so Stripe collects card details upfront even though no charge happens immediately.

How do I handle plan upgrades and downgrades?

The Customer Portal handles this automatically. When a user switches plans through the portal, Stripe fires a customer.subscription.updated event with the new price ID. Listen for that event in your webhook and update the user's plan in your database.

Can I use this with an auth system like Clerk or NextAuth?

Absolutely. After authentication, store the stripeCustomerId alongside the user record in your database. When creating a checkout session for a logged-in user, pass customer: existingStripeCustomerId instead of customer_creation: "always" — this links the subscription to their existing Stripe customer.


Conclusion

You now have a production-ready subscription billing flow in Next.js 16 — pricing page, Stripe Checkout, webhook handling for the full subscription lifecycle, and a customer portal for self-service billing management.

The pattern here scales cleanly: add more plans to PLANS, add more webhook event handlers as your app grows, and you'll never have to touch the core flow.

From here, the natural next steps are connecting a real database to persist subscription status, and gating your app's features based on the user's active plan.

📦 Source Code: View on GitHub


Useful Resources

Continue Learning

Next.jsStripeSubscriptionsTypeScriptServer ActionsWebhooksSaaSBillingStripe Checkout ApiStripe Checkout Sessions
Share On