← Blog

How to add Stripe subscriptions to a Next.js App Router app (2026)

Updated October 2026 · About a 12-minute read

Adding Stripe subscriptions to a Next.js App Router app doesn't take much code. It goes wrong in the small details: a webhook that parses the body before verifying it, a handler that runs the same event twice, or a "Pro" check that only exists in a client component. This guide covers the whole flow with stripe-node and Route Handlers, and keeps the parts that matter on the server.

Here's the plan:

  1. A route that creates a Checkout Session in subscription mode.
  2. A webhook route that reads the raw body, verifies the signature, and skips events it has already processed.
  3. Syncing subscription state into your own database.
  4. Gating features on the server, not in the browser.
  5. The customer portal for upgrades, cancellations, and card changes.
  6. The pitfalls that cost people an afternoon.

The examples assume TypeScript, Prisma with Postgres, and some kind of server-side auth() helper (Auth.js v5 has one). Swap in your own ORM or auth setup as needed.

Setup

Install the SDK and create one shared client. Use the server-only package so a careless import can't pull your secret key into a client bundle.

npm install stripe server-only
// lib/stripe.ts
import "server-only";
import Stripe from "stripe";

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

Recent versions of stripe-node pin a default API version to each SDK release, so you usually don't need to pass apiVersion. When you upgrade the package, read the changelog. Some response shapes change between API versions, and one of those changes is covered in the pitfalls below.

In the Stripe Dashboard, create a Product with a recurring Price, for example $12/month. Copy the price ID (price_...) into an env var. Don't hardcode amounts in your app. Stripe owns the price.

You'll need a few columns on your user (or team) table:

model User {
  id                   String    @id @default(cuid())
  email                String    @unique
  stripeCustomerId     String?   @unique
  stripeSubscriptionId String?   @unique
  stripePriceId        String?
  subscriptionStatus   String?   // active, trialing, past_due, canceled...
  currentPeriodEnd     DateTime?
}

model StripeEvent {
  id        String   @id      // the evt_... ID
  type      String
  createdAt DateTime @default(now())
}

1. The checkout session route

The browser should never decide which price it pays. It only sends a request. The server looks up the user, finds or creates their Stripe customer, and returns a Checkout URL.

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

export async function POST() {
  const session = await auth();
  if (!session?.user?.id) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const user = await db.user.findUniqueOrThrow({ where: { id: session.user.id } });

  let customerId = user.stripeCustomerId;
  if (!customerId) {
    const customer = await stripe.customers.create({
      email: user.email,
      metadata: { userId: user.id },
    });
    customerId = customer.id;
    await db.user.update({ where: { id: user.id }, data: { stripeCustomerId: customerId } });
  }

  const checkout = await stripe.checkout.sessions.create({
    mode: "subscription",
    customer: customerId,
    client_reference_id: user.id,
    line_items: [{ price: process.env.STRIPE_PRICE_PRO!, quantity: 1 }],
    subscription_data: { metadata: { userId: user.id } },
    success_url: `${process.env.APP_URL}/dashboard?checkout=success`,
    cancel_url: `${process.env.APP_URL}/pricing`,
  });

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

A few notes:

  • Create the customer yourself and reuse it. If you skip customer, Checkout creates a new customer every time, and soon one person has four customer records.
  • Put your user ID on the subscription metadata. Subscription events will carry it, so the webhook doesn't need to guess which user an event belongs to.
  • The success page is not proof of payment. Anyone can visit ?checkout=success. Only the webhook updates the plan.

On the client, a button POSTs to this route and then runs window.location.href = url. A Server Action that calls redirect(checkout.url!) also works.

2. The webhook route: raw body, signature, idempotency

This is the route people get wrong most often. Stripe signs the exact bytes it sends. If you parse the JSON and stringify it again, the signature check fails. In the App Router you don't need to disable a body parser. Just read the body with req.text() and don't call req.json().

// app/api/stripe/webhook/route.ts
import { headers } from "next/headers";
import Stripe from "stripe";
import { Prisma } from "@prisma/client";
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";
import { syncSubscription } from "@/lib/billing";

export const runtime = "nodejs";

export async function POST(req: Request) {
  const body = await req.text(); // raw body. Do not use req.json().
  const signature = (await headers()).get("stripe-signature");
  if (!signature) return new Response("Missing signature", { status: 400 });

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

  // Idempotency: record the event ID first. A duplicate hits the unique constraint.
  try {
    await db.stripeEvent.create({ data: { id: event.id, type: event.type } });
  } catch (e) {
    if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
      return new Response("Already processed", { status: 200 });
    }
    throw e;
  }

  try {
    switch (event.type) {
      case "checkout.session.completed": {
        const s = event.data.object;
        if (s.mode === "subscription" && s.subscription) {
          await syncSubscription(s.subscription as string);
        }
        break;
      }
      case "customer.subscription.created":
      case "customer.subscription.updated":
      case "customer.subscription.deleted":
        await syncSubscription(event.data.object.id);
        break;
    }
  } catch (err) {
    // Let Stripe retry: un-record the event and return a 500.
    await db.stripeEvent.delete({ where: { id: event.id } }).catch(() => {});
    return new Response("Handler failed", { status: 500 });
  }

  return new Response("ok", { status: 200 });
}

Why this shape works:

  • Verify the signature before you trust anything. constructEvent checks the HMAC and the timestamp tolerance. Without it, anyone who finds your URL can send a fake customer.subscription.updated.
  • Stripe delivers at least once. Retries, timeouts, and replays from the Dashboard all mean the same event can arrive more than once. A table keyed by event.id with a unique constraint is the simplest dedupe that holds up under concurrent deliveries.
  • Return a 500 on real failures and remove the dedupe row so the retry can run. If you return 200 after a failure, Stripe considers the event delivered and won't retry.
  • Use runtime = "nodejs". stripe-node can verify on edge runtimes with constructEventAsync, but the Node runtime has fewer surprises.

3. Syncing plan state

Don't build state from event payloads one field at a time. Events can arrive out of order. An updated event can show up before created. The reliable approach is to treat the event as a signal and fetch the current subscription from Stripe, then write the whole thing:

// lib/billing.ts
import "server-only";
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";

export async function syncSubscription(subscriptionId: string) {
  const sub = await stripe.subscriptions.retrieve(subscriptionId);
  const item = sub.items.data[0];
  const customerId = typeof sub.customer === "string" ? sub.customer : sub.customer.id;

  await db.user.update({
    where: { stripeCustomerId: customerId },
    data: {
      stripeSubscriptionId: sub.id,
      stripePriceId: item?.price.id ?? null,
      subscriptionStatus: sub.status,
      // On recent API versions, period dates are on the subscription item.
      currentPeriodEnd: item ? new Date(item.current_period_end * 1000) : null,
    },
  });
}

Matching on stripeCustomerId works because you created the customer in step 1. The userId in metadata is a backup if you ever need it.

Then define in one place what "paid" means:

// lib/plan.ts
const PRO_PRICES = new Set([process.env.STRIPE_PRICE_PRO!]);

export function isPro(u: { subscriptionStatus: string | null; stripePriceId: string | null }) {
  return (
    (u.subscriptionStatus === "active" || u.subscriptionStatus === "trialing") &&
    !!u.stripePriceId && PRO_PRICES.has(u.stripePriceId)
  );
}

Whether past_due still gets access is a business decision. Many apps allow a grace period while Stripe's Smart Retries try the card again. Whatever you choose, decide it here and nowhere else.

4. Gating features server-side

Hiding a button in a client component is UI, not access control. The check has to run on the server, right next to the data or action it protects. In the App Router, that means Server Components, Server Actions, and Route Handlers.

// app/dashboard/reports/page.tsx
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { isPro } from "@/lib/plan";

export default async function ReportsPage() {
  const session = await auth();
  if (!session?.user?.id) redirect("/login");

  const user = await db.user.findUniqueOrThrow({ where: { id: session.user.id } });
  if (!isPro(user)) redirect("/pricing");

  return <Reports userId={user.id} />;
}

Put the same check in every Server Action and API route that does paid work. A small requirePro() helper that throws or redirects keeps this short.

Why not middleware? Middleware is fine for cheap redirects, but it's a poor place to do the only access check. It runs separately from the code it protects, matchers are easy to get wrong, and a database call there adds latency to every request. Also, in March 2025 Next.js patched a vulnerability (CVE-2025-29927) that let a crafted header skip middleware entirely. Check access where the data is read.

5. The customer portal

Don't build your own screens for cancel, upgrade, and update card. Stripe's customer portal handles all of them. Turn on the features you want under Settings → Billing → Customer portal, then create a session on the server:

// app/api/portal/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";

export async function POST() {
  const session = await auth();
  if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  const user = await db.user.findUniqueOrThrow({ where: { id: session.user.id } });
  if (!user.stripeCustomerId) return NextResponse.json({ error: "No customer" }, { status: 400 });

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

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

Anything the user changes in the portal sends a customer.subscription.updated or .deleted event. Your existing webhook handles it, so there's no extra code to write. If you allow plan switching in the portal, add those prices to the portal configuration and to PRO_PRICES.

6. Common pitfalls

  • Parsing the body before verifying it. req.json() then JSON.stringify doesn't give you back the same bytes. Use req.text().
  • Using the wrong webhook secret. The Stripe CLI (stripe listen --forward-to localhost:3000/api/stripe/webhook) prints its own whsec_..., which is different from your Dashboard endpoint's secret. Test mode and live mode secrets are also different.
  • Granting access on the success URL. Users can close the tab, refresh the page, or type the URL. Only the webhook should grant access.
  • Not handling duplicate events. Two deliveries of the same event shouldn't send two welcome emails or grant credits twice.
  • Trusting event order. Fetch the subscription again instead of applying event payloads one after another.
  • Reading current_period_end from the subscription. In recent API versions it moved to subscription items. If you upgraded stripe-node and the field is now undefined, this is why.
  • Creating duplicate customers. Always pass customer to Checkout once you have one.
  • Middleware or redirects intercepting /api/stripe/webhook. Auth middleware that redirects the webhook to /login breaks it. So does a trailing-slash redirect, because Stripe doesn't follow redirects.
  • Doing slow work inside the handler. Stripe expects a quick 2xx. If you send emails or call other APIs, queue that work or keep it short.
  • Checking the plan only on the client. See section 4.

Testing it locally

stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
# in another terminal
stripe trigger customer.subscription.updated

For a full run-through, use test card 4242 4242 4242 4242 in Checkout, check that the database row changes, open the portal, cancel, and check that access goes away when the subscription ends. Replay one event from the Dashboard to confirm the dedupe table catches it.

Or skip the wiring

Full disclosure: I sell a starter kit that already does all of this. ShipEmber is a Next.js App Router + TypeScript starter with Stripe Checkout, the customer portal, and signature-verified, idempotent webhooks (a StripeEvent table records processed event IDs, as shown above). It also includes a protected dashboard with server-side plan gating (auth() on the server, no middleware), Auth.js v5 (Google, GitHub, email magic links), Prisma 7 + Postgres, Tailwind v4, marketing pages, a one-file rebrand config, Vitest tests, and GitHub CI.

It's a one-time download: $49 for launch week, then $79. You get lifetime v1 updates, can use it in unlimited personal and client projects, and have 14 days to ask for a refund. If you'd rather build it yourself, everything above should get you there. If you'd rather start from working code, see pricing.