Adding Stripe to a Next.js project used to mean a tangle of /pages/api handlers, manual body parsing, and copy-paste tutorials that quietly broke the moment the App Router landed. Honestly, I've lost weekends to that mess. In 2026, the picture is much cleaner: Server Actions handle Checkout creation, Route Handlers verify webhook signatures with the Web Fetch API, and Stripe's Embedded Checkout keeps customers on your domain. This guide walks through a production-grade integration end to end — Checkout Sessions, signed webhooks, the full subscription lifecycle, the Customer Portal, and the gotchas most blog posts skip right past.
By the end, you'll have a working flow for one-time payments and recurring subscriptions, with proper signature verification, a database that stays in sync with Stripe, and a customer portal your users can manage themselves. No 3 AM "why did the renewal not record" debugging sessions required.
Why Stripe + Next.js App Router Looks Different in 2026
Three things changed since the Pages Router era, and basically every old tutorial misses at least one of them:
- Server Actions replace most checkout API routes. A form posting to a
"use server"function is shorter, type-safe end to end, and avoids exposing endpoints that don't really need to be reachable from anywhere except your own UI. - Webhooks still need Route Handlers. Stripe needs a static URL to POST to, and the App Router's Web-standard
Requestobject means you read the raw body withrequest.text()instead of fiddling withbodyParserconfig. - Embedded Checkout is the default UX. Stripe's hosted redirect still works fine, but the embedded UI mode keeps the customer on your domain, reduces drop-off, and is now what Stripe's own examples lead with.
The architecture you'll build looks like this: a Server Action creates a Checkout Session, the user pays, Stripe POSTs a checkout.session.completed event to your webhook route, and the route handler updates your database. For subscriptions, additional events keep your billing state in sync over time.
That's it. Four moving parts.
Project Setup and Environment Variables
Start from a fresh Next.js 16 App Router project (or just drop these steps into an existing one):
npx create-next-app@latest stripe-demo --typescript --tailwind --app
cd stripe-demo
npm install stripe @stripe/stripe-js @stripe/react-stripe-js
You'll need three environment variables. Add them to .env.local:
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
Only the publishable key is exposed to the browser (note the NEXT_PUBLIC_ prefix). The secret key and webhook secret must stay server-side. If they ever appear in a "use client" file or a fetched JSON payload — rotate them immediately. Don't wait, don't "I'll do it tomorrow." Rotate.
Create a single Stripe client to import everywhere on the server:
// lib/stripe.ts
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2025-09-30.clover",
typescript: true,
});
Pin apiVersion explicitly. Without it, Stripe uses whatever version is associated with your account, which can shift behavior under your feet when Stripe rolls out new defaults. I learned that one the hard way during a quiet Friday deploy.
Creating a Checkout Session with a Server Action
Server Actions are the cleanest way to start a Checkout flow. The action runs server-side, talks to Stripe with your secret key, and returns either a redirect URL (hosted Checkout) or a client secret (Embedded Checkout). The client never sees the price ID it shouldn't be allowed to choose.
One-time Payment with Hosted Checkout
// app/actions/checkout.ts
"use server";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { stripe } from "@/lib/stripe";
export async function createCheckoutSession(formData: FormData) {
const productId = formData.get("productId") as string;
// Look up the price server-side. NEVER trust an amount from the client.
const product = await db.query.products.findFirst({
where: eq(products.id, productId),
});
if (!product) throw new Error("Product not found");
const origin = (await headers()).get("origin")!;
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [{ price: product.stripePriceId, quantity: 1 }],
success_url: `${origin}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/cart`,
metadata: { productId: product.id, userId: await getCurrentUserId() },
});
redirect(session.url!);
}
Wire it to a form:
// app/products/[id]/buy-button.tsx
import { createCheckoutSession } from "@/app/actions/checkout";
export function BuyButton({ productId }: { productId: string }) {
return (
<form action={createCheckoutSession}>
<input type="hidden" name="productId" value={productId} />
<button type="submit">Buy now</button>
</form>
);
}
Three details matter here:
- Server-side price lookup. The form submits a product ID, not a price. The action resolves it against your database. (If you ever feel tempted to pass an amount from the client — just don't.)
metadatais your friend. Stuff your internal IDs in there. When the webhook fires later, you'll need them to match the Stripe event back to a user or order.- Use
redirect(), notres.redirect(). Theredirecthelper fromnext/navigationworks cleanly inside Server Actions and throws a special error Next.js handles automatically.
Embedded Checkout (the 2026 default)
For a smoother UX, return a client secret and mount Stripe's embedded form on your page:
// app/actions/embedded-checkout.ts
"use server";
import { headers } from "next/headers";
import { stripe } from "@/lib/stripe";
export async function createEmbeddedSession(priceId: string) {
const origin = (await headers()).get("origin")!;
const session = await stripe.checkout.sessions.create({
ui_mode: "embedded",
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
return_url: `${origin}/return?session_id={CHECKOUT_SESSION_ID}`,
});
return { clientSecret: session.client_secret };
}
// app/checkout/page.tsx
"use client";
import { loadStripe } from "@stripe/stripe-js";
import {
EmbeddedCheckoutProvider,
EmbeddedCheckout,
} from "@stripe/react-stripe-js";
import { useCallback } from "react";
import { createEmbeddedSession } from "@/app/actions/embedded-checkout";
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
export default function CheckoutPage({ priceId }: { priceId: string }) {
const fetchClientSecret = useCallback(
() => createEmbeddedSession(priceId).then((r) => r.clientSecret!),
[priceId],
);
return (
<EmbeddedCheckoutProvider stripe={stripePromise} options={{ fetchClientSecret }}>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
);
}
Embedded Checkout keeps the customer on your domain, which is better for branded SaaS flows and avoids that brief blank screen during the redirect. A small thing, but it really does make the experience feel more polished.
Webhook Route Handler with Signature Verification
So, this is where most tutorials go wrong. Stripe signs every webhook with HMAC-SHA256, and verification only works if you pass the raw, unparsed body to stripe.webhooks.constructEvent. In the App Router, the secret is to use await request.text() — never request.json(). Burn that into your memory.
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import type Stripe from "stripe";
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "Missing signature" }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
console.error("Webhook signature verification failed:", message);
return NextResponse.json({ error: `Webhook Error: ${message}` }, { status: 400 });
}
try {
await handleEvent(event);
} catch (err) {
console.error("Webhook handler failed:", err);
// Return 500 so Stripe retries the event.
return NextResponse.json({ error: "Handler failed" }, { status: 500 });
}
return NextResponse.json({ received: true });
}
async function handleEvent(event: Stripe.Event) {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
await fulfillOrder(session);
break;
}
case "customer.subscription.created":
case "customer.subscription.updated":
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
await syncSubscription(subscription);
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
await flagPaymentFailure(invoice);
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
}
Why your signature might fail in production
Even with the correct code, four things commonly break webhook verification:
- Middleware that touches the body. If your
middleware.tscallsrequest.json()or rewrites the request, the body Stripe sees won't match the signature. Usematcherto exclude/api/webhooks/*. - A reverse proxy that re-encodes the payload. Some CDNs gzip or decode in transit. Make sure your hosting provider passes the body through unchanged.
- Wrong secret. The local
stripe listenCLI prints a different secret than your dashboard webhook endpoint. Use the right one for the environment (this trips people up constantly). - Clock skew. Stripe rejects timestamps older than 5 minutes by default. If your server clock is wrong, signatures fail with a timing error — not a "wrong key" one, which makes the cause harder to spot.
Subscription Lifecycle: Keeping Your DB in Sync
For SaaS, the webhook is your source of truth — not the Checkout return URL. The user might close the browser before the redirect, network issues might delay it, or they might come back to your app from a different device entirely. Always update subscription state from webhooks. Always.
A minimal subscription table (using Drizzle ORM as an example) might look something like this:
// db/schema.ts
export const subscriptions = pgTable("subscriptions", {
id: text("id").primaryKey(), // Stripe subscription ID
userId: text("user_id").notNull().references(() => users.id),
status: text("status").notNull(), // active, trialing, past_due, canceled
priceId: text("price_id").notNull(),
currentPeriodEnd: timestamp("current_period_end").notNull(),
cancelAtPeriodEnd: boolean("cancel_at_period_end").notNull().default(false),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
And the sync function called from your webhook:
// lib/subscriptions.ts
import type Stripe from "stripe";
import { db } from "@/db";
import { subscriptions } from "@/db/schema";
export async function syncSubscription(sub: Stripe.Subscription) {
const userId = sub.metadata.userId;
if (!userId) {
console.warn(`Subscription ${sub.id} has no userId metadata`);
return;
}
await db
.insert(subscriptions)
.values({
id: sub.id,
userId,
status: sub.status,
priceId: sub.items.data[0].price.id,
currentPeriodEnd: new Date(sub.current_period_end * 1000),
cancelAtPeriodEnd: sub.cancel_at_period_end,
})
.onConflictDoUpdate({
target: subscriptions.id,
set: {
status: sub.status,
priceId: sub.items.data[0].price.id,
currentPeriodEnd: new Date(sub.current_period_end * 1000),
cancelAtPeriodEnd: sub.cancel_at_period_end,
updatedAt: new Date(),
},
});
}
The events you'll want to listen for in production:
| Event | Why it matters |
|---|---|
checkout.session.completed | Initial purchase — fulfill order or grant access. |
customer.subscription.created | New subscription begins. Insert your DB row. |
customer.subscription.updated | Plan change, trial converted, cancellation scheduled. |
customer.subscription.deleted | Subscription ended (canceled or unpaid). Revoke access. |
invoice.payment_succeeded | Renewal succeeded — extend period end. |
invoice.payment_failed | Card declined. Email customer, start dunning. |
The Customer Portal: Self-Service Cancellation and Plan Changes
Don't build your own subscription management UI. Seriously, don't. Stripe's Customer Portal handles plan changes, payment method updates, billing history, and cancellations — and it stays in sync with the Stripe Dashboard configuration. Building this yourself is weeks of work for a feature your users barely notice when it works.
// app/actions/portal.ts
"use server";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { stripe } from "@/lib/stripe";
import { getCurrentUser } from "@/lib/auth";
export async function openCustomerPortal() {
const user = await getCurrentUser();
if (!user?.stripeCustomerId) throw new Error("No Stripe customer");
const origin = (await headers()).get("origin")!;
const session = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${origin}/account`,
});
redirect(session.url);
}
Configure the portal once in the Stripe Dashboard (Settings → Billing → Customer portal). Choose which products users can switch to, whether they can cancel immediately or at period end, and what payment methods to allow. After that, your code is two lines. That's the whole feature.
Local Testing with the Stripe CLI
Webhooks can't reach localhost from the public internet, but the Stripe CLI tunnels them for you:
# Install (macOS)
brew install stripe/stripe-cli/stripe
# Log in once
stripe login
# Forward webhooks to your dev server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
The CLI prints a webhook signing secret like whsec_abc123... — paste that into .env.local as STRIPE_WEBHOOK_SECRET for local development. Important: this is different from the secret in your dashboard webhook endpoint, which you'll use in production. Mixing those two up is one of the most common reasons "it worked locally" turns into a deploy-day fire.
Trigger events on demand:
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed
Each command fires a synthetic event through your tunnel. Your route handler logs and DB updates run exactly as they would in production — no need to make real test purchases just to debug a switch statement.
Production Deployment Checklist
Before you flip to live mode, run through these:
- Switch to live keys. Replace test-mode
sk_test_andpk_test_values with theirsk_live_andpk_live_counterparts in your hosting provider's environment variables. - Create a live webhook endpoint in the Stripe Dashboard pointing to
https://yourdomain.com/api/webhooks/stripe. Copy the new signing secret intoSTRIPE_WEBHOOK_SECRET. - Subscribe to only the events you handle. Don't accept every event type — it just adds noise and processing time.
- Make webhook handlers idempotent. Stripe retries failed deliveries up to 3 days. Use
onConflictDoUpdateor checkevent.idagainst a processed-events table to avoid double-fulfilling orders. - Set route handler timeout headroom. If your handler takes more than 10 seconds, return 200 quickly and process asynchronously (e.g., enqueue a background job). Stripe treats a non-2xx response as a failure and retries.
- Add monitoring. Track webhook 4xx/5xx rates separately from your main app. A spike in 400s usually means signature verification is breaking somewhere.
Common Pitfalls (and Fixes)
"No signatures found matching the expected signature for payload"
You're parsing the body before passing it to constructEvent. The fix is always const body = await request.text(); — not request.json(), not req.body. Every time I see this error in someone's Discord, it's the same root cause.
Webhook works locally but fails on Vercel
Two suspects: middleware running on the webhook route (add a matcher exclusion), or you forgot to update STRIPE_WEBHOOK_SECRET to the production webhook's secret (it's different from the CLI's local secret, as mentioned above).
Subscription marked as canceled but user still has access
You're checking the subscription's status field in your DB, but you forgot to handle customer.subscription.deleted. Add it to your webhook switch and revoke access there.
Embedded Checkout shows blank screen
Almost always a missing or wrong NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY. Check the browser console — Stripe.js logs a pretty clear error when it can't initialize.
Frequently Asked Questions
Should I use Server Actions or API routes for Stripe checkout?
Use Server Actions for creating Checkout Sessions and Customer Portal sessions — they're called from your own UI, benefit from end-to-end type safety, and don't need a public URL. Use Route Handlers only for webhooks, where Stripe needs a static endpoint to POST to. This split is the recommended 2026 pattern, and honestly, it just feels right once you've set it up a couple of times.
How do I test Stripe webhooks on localhost with Next.js?
Install the Stripe CLI, run stripe listen --forward-to localhost:3000/api/webhooks/stripe, and copy the whsec_ secret it prints into .env.local. Trigger events with stripe trigger <event-name> to verify your handler works without making real test purchases.
Why is my Stripe webhook signature verification failing in App Router?
The most common cause is calling request.json() instead of request.text(). Stripe signs the raw bytes of the body, so any parsing or re-serialization breaks the signature. Other usual suspects: middleware modifying the body, a wrong webhook secret (local vs. production differ), or a CDN re-encoding the payload.
Should I use Stripe Embedded Checkout or hosted Checkout?
Embedded Checkout keeps users on your domain, which is better for branded SaaS and reduces drop-off. Hosted Checkout is simpler — one redirect call, no client-side Stripe.js mounting — and is fine for one-off purchases or B2B flows where users won't blink at the redirect. Both are PCI-compliant; the choice is purely UX.
How do I sync Stripe subscription data to my database?
Subscribe to customer.subscription.created, customer.subscription.updated, and customer.subscription.deleted in your webhook. Use onConflictDoUpdate (or your ORM's upsert equivalent) keyed on the Stripe subscription ID. Always treat the webhook as your source of truth — don't rely on the success_url redirect, since users can close the browser before it fires.
Do I need to handle invoice events for subscriptions?
Yes, especially invoice.payment_failed. The subscription status doesn't change to past_due immediately; you'll learn about a failed renewal first via the invoice event. Use it to email the customer, kick off a dunning flow, or temporarily flag the account.
Wrapping Up
The 2026 Stripe + Next.js stack is mostly about knowing where each piece belongs: Server Actions for outbound calls (Checkout, Portal), Route Handlers for inbound webhooks, the Customer Portal for self-service, and the database synced exclusively from webhook events. Get those four right and the rest — trials, plan upgrades, dunning, refunds — falls into place because Stripe sends an event for everything.
If you're building a SaaS from scratch, pair this with role-based access control on the Next.js side so subscription tier maps cleanly to feature gating. The webhook handler updates the subscription row; your middleware or server components check it on every request. That's the whole loop. Now go ship it.