Skip to content

Building a production-ready Stripe billing system in Next.js (2026 guide)

A complete, hardened walkthrough for wiring Stripe Billing into a Next.js SaaS — Checkout, Customer Portal, webhooks, metered usage, free trials, dunning, and the failure modes that quietly break in production.

12 min read
By Digitizia

Stripe Billing is the default backend for SaaS subscriptions in 2026, and wiring it into a Next.js app has gotten dramatically easier with the App Router and Server Actions. The catch is that the easy path skips half a dozen production-critical pieces — webhook idempotency, customer portal, proration, dunning, plan changes — that quietly break the moment you have real revenue. This is the version we ship by default on every SaaS we build.

Get the data model right first

Before any code, the data model. Every customer in your app maps to one Stripe customer. Every subscription state lives in your database — never query Stripe live for it. Stripe is the source of truth, but your database is the read model that keeps your app fast.

// drizzle schema
export const organizations = pgTable("organizations", {
  id: uuid("id").primaryKey().defaultRandom(),
  name: text("name").notNull(),
  stripeCustomerId: text("stripe_customer_id").unique(),
  stripeSubscriptionId: text("stripe_subscription_id"),
  plan: text("plan").$type<"free" | "starter" | "growth" | "scale">().notNull().default("free"),
  status: text("status").$type<"active" | "trialing" | "past_due" | "canceled">().default("active"),
  currentPeriodEnd: timestamp("current_period_end"),
});

Stripe Checkout via Server Action

Use Stripe Checkout for the upgrade flow — it handles cards, wallets, tax IDs, 3DS, and SCA without you maintaining a single payment form. The Server Action below creates a session and redirects:

// app/(app)/billing/upgrade.ts
"use server";
import Stripe from "stripe";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function startCheckout(priceId: string) {
  const { orgId, userEmail } = await auth();
  const org = await db.query.organizations.findFirst({ where: eq(organizations.id, orgId) });

  let customerId = org?.stripeCustomerId;
  if (!customerId) {
    const customer = await stripe.customers.create({
      email: userEmail,
      metadata: { orgId },
    });
    customerId = customer.id;
    await db.update(organizations).set({ stripeCustomerId: customerId }).where(eq(organizations.id, orgId));
  }

  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    customer: customerId,
    line_items: [{ price: priceId, quantity: 1 }],
    subscription_data: { trial_period_days: 14 },
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing?success=1`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing`,
    allow_promotion_codes: true,
    automatic_tax: { enabled: true },
  });

  redirect(session.url!);
}

Customer Portal — don't build your own

The Stripe Customer Portal handles updating cards, downloading invoices, switching plans, and cancelling. Building your own UI for these is months of work and a constant source of edge-case bugs. Use the portal:

"use server";
export async function openCustomerPortal() {
  const { orgId } = await auth();
  const org = await db.query.organizations.findFirst({ where: eq(organizations.id, orgId) });
  if (!org?.stripeCustomerId) throw new Error("No Stripe customer");

  const portal = await stripe.billingPortal.sessions.create({
    customer: org.stripeCustomerId,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing`,
  });
  redirect(portal.url);
}

Webhooks: the part that breaks in production

Webhooks are how your database stays in sync with Stripe. Three rules that every production-grade Stripe integration follows:

  1. Verify the signature on every webhook. Never trust the body alone.
  2. Store every event ID and skip duplicates — Stripe retries on any non-2xx, and your handler will run twice eventually.
  3. Process the small set of events you care about, and respond 200 to everything else.
// app/api/webhooks/stripe/route.ts
import { NextResponse } from "next/server";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

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

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

  // Idempotency: skip if we have already processed this event
  const seen = await db.query.stripeEvents.findFirst({ where: eq(stripeEvents.id, event.id) });
  if (seen) return NextResponse.json({ received: true });
  await db.insert(stripeEvents).values({ id: event.id, type: event.type });

  switch (event.type) {
    case "customer.subscription.created":
    case "customer.subscription.updated":
    case "customer.subscription.deleted": {
      const sub = event.data.object as Stripe.Subscription;
      await syncSubscriptionToDb(sub);
      break;
    }
    case "invoice.payment_failed": {
      await notifyPaymentFailed((event.data.object as Stripe.Invoice).customer as string);
      break;
    }
  }

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

Metered billing for usage-based plans

If your SaaS charges per AI message, per minute of voice, per booked appointment, you need metered billing. Stripe's new Meters API in 2026 is the right primitive:

// after a billable action
await stripe.billing.meterEvents.create({
  event_name: "ai_messages",
  payload: {
    stripe_customer_id: org.stripeCustomerId!,
    value: "1",
  },
});

Always batch low-value events. If a single user action produces five billable units, send one event with value=5 — not five events with value=1.

Free trials without credit cards

A trial gated behind a credit card converts higher per signup but has dramatically lower top-of-funnel signups. The pattern we ship by default in 2026: a free tier in your own database for the first 14 days with no Stripe involved, then redirect to Checkout when the trial ends. Stripe's trial_period_days is right when you want the card upfront; skip it when you don't.

Pitfalls we see every project

  1. Treating the database as cache for Stripe and querying Stripe live on page render. Slow and rate-limited; webhooks are the right answer.
  2. Forgetting to handle 'past_due' status. The user's card failed, the subscription is still in Stripe, and your app silently lets them keep using paid features.
  3. Using test mode keys in staging environments that share a database with production. The webhook events get crossed and customers go missing.
  4. Hardcoding price IDs. Use environment variables and put real prices in Stripe — never re-deploy to change a price.

The takeaway

The path from blank Next.js app to production Stripe billing is roughly two days of focused work if you copy the patterns above and skip the parts (custom card forms, custom invoice UIs) that Stripe already handles better than you can. Where projects get stuck is webhook idempotency, the customer portal, and plan changes mid-cycle — all solved problems if you do them in the right order.

Frequently asked questions

Should I use Stripe Checkout or build my own payment form?

Stripe Checkout, every time, unless you have a very specific reason. Checkout handles 3DS, SCA, Apple Pay, Google Pay, tax IDs, and address collection out of the box, on a hosted page Stripe maintains. Building your own with Stripe Elements is meaningful work and creates an ongoing maintenance burden for marginal UX gains.

Do I need Stripe Connect or just regular Stripe?

Use regular Stripe Billing if your SaaS sells subscriptions to customers. Use Stripe Connect only if you are a marketplace splitting payments between a buyer and a third-party seller. The two products solve different problems — most SaaS founders accidentally over-engineer toward Connect when they don't need it.

How do I prevent webhook duplicates from corrupting my database?

Store the event.id of every Stripe webhook in a dedicated table and check it before processing. Stripe retries on any non-2xx response, so the same event will arrive more than once eventually. Idempotency by event ID is the cleanest solution and adds two lines of code.