# What You’ll Build#
This guide shows a production-ready setup for Next.js Stripe subscriptions with three non-negotiables: reliable billing state, self-serve customer management, and deterministic access control.
You’ll implement:
- Subscription purchase flow that attaches Stripe objects to your user records.
- Stripe Billing Portal for upgrades, downgrades, cancellations, and invoice downloads.
- Verified webhooks with idempotency to avoid double-processing.
- A clean mapping from subscription state to feature entitlements.
- A testing strategy using Stripe CLI, plus common failure modes and security considerations.
If you want a broader API pattern for third-party systems, also see our API integration guide.
# Prerequisites and Architecture#
You need one “source of truth” for access. In production, that must be your database, not Stripe API calls on every request and not client-side checks.
Stack assumptions#
- Next.js 14 or 15 (App Router)
- Node runtime for webhook route (signature verification needs raw body)
- Stripe Node SDK
- Database (PostgreSQL recommended) via Prisma or similar
What goes where#
| Concern | Stripe | Your app |
|---|---|---|
| Pricing (products, prices, trials) | Define in Dashboard or via API | Reference Price IDs in config |
| Customer identity | Customer object | Map userId to stripeCustomerId |
| Subscription lifecycle | Subscription, Invoice, PaymentIntent | Persist current status, renewal dates, plan |
| Self-serve management | Billing Portal | Provide Portal session link |
| Access control | — | Entitlements derived from subscription record |
| Truth updates | Webhooks | Verified + idempotent writes |
🎯 Key Takeaway: Treat Stripe as the billing engine, and your database as the access authority updated via webhooks.
# Step 1: Model Plans, Trials, and Entitlements#
Avoid encoding “Pro”, “Team”, and “Enterprise” logic all over your codebase. Keep a single plan map that references Stripe Price IDs and defines entitlements.
Plan configuration#
Store Price IDs in environment variables so you can separate test and live mode cleanly.
| Plan | Stripe Price ID env var | Trial days | Example entitlements |
|---|---|---|---|
| Starter | STRIPE_PRICE_STARTER | 7 | Basic features, 1 workspace |
| Pro | STRIPE_PRICE_PRO | 14 | Advanced features, 5 workspaces |
| Team | STRIPE_PRICE_TEAM | 14 | SSO, 20 workspaces, priority support |
Define entitlements in code as a compact “capabilities object”. Keep it stable and versionable.
// lib/billing/plans.ts
export type PlanKey = "starter" | "pro" | "team";
export const PLANS: Record<PlanKey, {
priceId: string;
trialDays: number;
entitlements: {
workspaces: number;
sso: boolean;
prioritySupport: boolean;
advancedExports: boolean;
};
}> = {
starter: {
priceId: process.env.STRIPE_PRICE_STARTER!,
trialDays: 7,
entitlements: { workspaces: 1, sso: false, prioritySupport: false, advancedExports: false },
},
pro: {
priceId: process.env.STRIPE_PRICE_PRO!,
trialDays: 14,
entitlements: { workspaces: 5, sso: false, prioritySupport: false, advancedExports: true },
},
team: {
priceId: process.env.STRIPE_PRICE_TEAM!,
trialDays: 14,
entitlements: { workspaces: 20, sso: true, prioritySupport: true, advancedExports: true },
},
};Database tables you actually need#
Keep billing data minimal but sufficient for access checks, customer support, and reconciliation.
| Table | Key fields | Why it matters |
|---|---|---|
User | id, email, stripeCustomerId | Stable mapping user to Stripe Customer |
Subscription | userId, stripeSubscriptionId, status, priceId, currentPeriodEnd, cancelAtPeriodEnd | Fast entitlement resolution |
WebhookEvent | stripeEventId, type, processedAt | Idempotency and audit trail |
EntitlementSnapshot (optional) | userId, json, updatedAt | Fast reads, feature flags, reporting |
A good rule: compute entitlements from Subscription fields at runtime, and only store a snapshot if reads are extremely hot or you need historical reporting.
# Step 2: Create Checkout for New Subscriptions#
For most SaaS teams, Stripe Checkout is the fastest path to a compliant, localized, tax-friendly purchase flow.
Create a Checkout Session route#
Use a server-side route that accepts a planKey, loads the matching priceId, and creates a Checkout Session in subscription mode.
// app/api/billing/checkout/route.ts
import Stripe from "stripe";
import { NextResponse } from "next/server";
import { PLANS, PlanKey } from "@/lib/billing/plans";
import { getCurrentUser } from "@/lib/auth/getCurrentUser";
export const runtime = "nodejs";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2025-02-24.acacia",
});
export async function POST(req: Request) {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { planKey } = await req.json();
const plan = PLANS[planKey as PlanKey];
if (!plan) return NextResponse.json({ error: "Invalid plan" }, { status: 400 });
const session = await stripe.checkout.sessions.create({
mode: "subscription",
customer: user.stripeCustomerId ?? undefined,
customer_email: user.stripeCustomerId ? undefined : user.email,
line_items: [{ price: plan.priceId, quantity: 1 }],
subscription_data: { trial_period_days: plan.trialDays },
allow_promotion_codes: true,
success_url: `${process.env.APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/pricing`,
metadata: { userId: user.id, planKey },
});
return NextResponse.json({ url: session.url });
}This route intentionally does not grant access. Access changes only after webhook processing.
⚠️ Warning: Don’t unlock features in
success_urlbased on query params. Users can land on that page without a successful payment, and retries can create out-of-order state. Webhooks are the source of truth.
Attach customer ID to your user#
After the first checkout, Stripe will create a Customer if you passed customer_email. Capture the customer from the checkout.session.completed webhook and persist it to User.stripeCustomerId.
# Step 3: Add Stripe Billing Portal (Self-Serve)#
The Billing Portal reduces support load and increases retention by making upgrades and payment fixes frictionless. Stripe has published that failed payments are a major source of involuntary churn; giving customers a self-serve way to update payment methods is one of the highest ROI billing improvements you can ship.
Create a Portal session route#
// app/api/billing/portal/route.ts
import Stripe from "stripe";
import { NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth/getCurrentUser";
export const runtime = "nodejs";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2025-02-24.acacia",
});
export async function POST() {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!user.stripeCustomerId) {
return NextResponse.json({ error: "No Stripe customer" }, { status: 400 });
}
const session = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.APP_URL}/billing`,
});
return NextResponse.json({ url: session.url });
}In Stripe Dashboard, configure Portal settings for allowed updates. Typical production setup:
| Portal setting | Recommendation | Why |
|---|---|---|
| Cancel subscription | Allow | Reduces support tickets |
| Update payment method | Allow | Reduces failed renewal churn |
| Update plan | Allow, but restrict to your product | Prevent mismatched prices |
| Invoice history | Allow | Fewer billing questions |
| Proration | Decide based on your business | Avoid surprise invoices |
# Step 4: Webhooks That Are Verified, Idempotent, and Deterministic#
Webhooks are where most “it worked locally” billing implementations break. You need to handle: retries, out-of-order delivery, and partial failure.
Choose events you actually need#
A minimal, robust set:
| Event | Use it for | Notes |
|---|---|---|
checkout.session.completed | Link user to customer, read subscription ID | Not a guarantee of paid invoice for some flows |
customer.subscription.created | Create local subscription row | Useful when subscription is created outside Checkout |
customer.subscription.updated | Status changes, cancellations, plan swaps | Most state changes flow through here |
customer.subscription.deleted | Hard delete or ended subscription | Mark as canceled immediately |
invoice.payment_succeeded | Mark paid period, handle renewals | Strong signal subscription is funded |
invoice.payment_failed | Dunning flows, restrict access policy | Decide grace periods |
You can start with fewer, but you must cover the lifecycle that impacts access.
Implement the webhook route with signature verification#
Stripe requires using the raw request body to verify signatures. In Next.js App Router, req.text() is commonly used for this.
// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { upsertSubscriptionFromStripe } from "@/lib/billing/sync";
import { markWebhookEventProcessed, wasWebhookEventProcessed } from "@/lib/billing/idempotency";
export const runtime = "nodejs";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2025-02-24.acacia",
});
export async function POST(req: Request) {
const sig = (await headers()).get("stripe-signature");
if (!sig) return NextResponse.json({ error: "Missing signature" }, { status: 400 });
const rawBody = await req.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
if (await wasWebhookEventProcessed(event.id)) {
return NextResponse.json({ received: true });
}
try {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
await upsertSubscriptionFromStripe({ checkoutSession: session });
break;
}
case "customer.subscription.created":
case "customer.subscription.updated":
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
await upsertSubscriptionFromStripe({ subscription: sub });
break;
}
case "invoice.payment_succeeded":
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
await upsertSubscriptionFromStripe({ invoice });
break;
}
default:
break;
}
await markWebhookEventProcessed(event.id, event.type);
return NextResponse.json({ received: true });
} catch (e) {
// Stripe will retry on non-2xx responses
return NextResponse.json({ error: "Webhook handler failed" }, { status: 500 });
}
}Idempotency strategy that works under retries#
Stripe will retry webhooks for up to days if your endpoint returns non-2xx. You must handle duplicates even when you return 200, because delivery can happen twice.
A practical approach:
- Create a
WebhookEventrow keyed bystripeEventId. - Put a unique constraint on
stripeEventId. - If insert fails due to uniqueness, skip processing.
If you also need “exactly once” semantics across multiple writes, use a DB transaction that includes:
- 1Insert into
WebhookEvent - 2Upsert the subscription state
Sync logic: normalize Stripe objects into your schema#
Your access checks should not depend on dozens of Stripe fields. Map to a stable local model.
// lib/billing/normalize.ts
import Stripe from "stripe";
export function normalizeSubscription(sub: Stripe.Subscription) {
return {
stripeSubscriptionId: sub.id,
stripeCustomerId: sub.customer as string,
status: sub.status,
priceId: (sub.items.data[0]?.price?.id ?? null) as string | null,
cancelAtPeriodEnd: sub.cancel_at_period_end,
currentPeriodEnd: new Date(sub.current_period_end * 1000),
trialEnd: sub.trial_end ? new Date(sub.trial_end * 1000) : null,
};
}Then upsertSubscriptionFromStripe can:
- Resolve
userIdfromstripeCustomerId(must be stored on user). - Upsert
SubscriptionbystripeSubscriptionId. - Update
User.stripeCustomerIdwhen you first see it.
ℹ️ Note: Some events include only IDs unless you expand objects. In webhooks, Stripe typically includes full objects for the event’s primary object, but be conservative and handle missing nested fields.
# Step 5: Compute Entitlements from Subscription State#
Your app should answer one question quickly: “What can this user do right now?”
Define access policy#
A common production policy:
- Active access when
statusisactiveortrialing. - Optional grace period when
statusispast_duefor less than N days. - No access when
canceled,unpaid, orincomplete_expired.
Also consider: cancellations with cancel_at_period_end still allow access until current_period_end.
Entitlement resolution function#
This function should be used by API routes and server components. It must not call Stripe.
// lib/billing/entitlements.ts
import { PLANS, PlanKey } from "@/lib/billing/plans";
const PRICE_ID_TO_PLAN: Record<string, PlanKey> = Object.fromEntries(
Object.entries(PLANS).map(([k, v]) => [v.priceId, k as PlanKey])
);
export function resolveEntitlements(subscription: {
status: string;
priceId: string | null;
currentPeriodEnd: Date;
trialEnd: Date | null;
cancelAtPeriodEnd: boolean;
}) {
const now = Date.now();
const periodEnd = subscription.currentPeriodEnd.getTime();
const isActive =
subscription.status === "active" ||
subscription.status === "trialing" ||
(subscription.status === "canceled" && now < periodEnd);
const planKey = subscription.priceId ? PRICE_ID_TO_PLAN[subscription.priceId] : null;
if (!isActive || !planKey) {
return { isActive: false, planKey: null, entitlements: null };
}
return {
isActive: true,
planKey,
entitlements: PLANS[planKey].entitlements,
};
}Enforce entitlements in your product#
Use entitlements at decision points, not sprinkled across UI.
Examples:
- Workspace creation: compare
currentWorkspaceCounttoentitlements.workspaces. - SSO routes: require
entitlements.sso === true. - Exports: allow advanced exports only for Pro and above.
This makes upgrades measurable. You can log “feature blocked due to plan” events to see what drives conversion, and you can connect that to observability practices from our web app observability guide.
# Step 6: Testing Locally with Stripe CLI (and What to Verify)#
Stripe CLI is the fastest way to validate your webhook handler, including signature verification and retries.
Install and login#
stripe login
stripe --versionForward webhooks to Next.js#
Run your app on http://localhost:3000, then:
stripe listen --forward-to localhost:3000/api/stripe/webhookCopy the webhook signing secret the CLI prints and set it as STRIPE_WEBHOOK_SECRET in your local env.
Trigger relevant events#
stripe trigger customer.subscription.created
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_succeeded
stripe trigger invoice.payment_failedWhat a good test checklist looks like#
| Test | How | Expected result |
|---|---|---|
| Signature verification | Send a request without signature | Endpoint returns 400 |
| Idempotency | Re-send same event ID | No duplicate DB writes |
| Out-of-order events | Trigger update before created | Upsert still results in consistent row |
| Failure retry | Force DB error once | Stripe retries and eventually succeeds |
| Plan mapping | Use different Price IDs | Correct planKey and entitlements |
| Cancellation behavior | Set cancel_at_period_end | Access remains until current_period_end |
💡 Tip: In staging, configure a separate webhook endpoint and Stripe “test mode” keys. Never point Stripe test mode at production data stores.
# Step 7: Failure Modes You Must Design For#
Billing failures aren’t edge cases. They’re normal operations at scale: card expirations, insufficient funds, bank authentication issues, and network failures.
Common failure modes and mitigations#
| Failure mode | What happens | Mitigation |
|---|---|---|
| Webhook handler returns 500 | Stripe retries for hours or days | Make handler fast, transactional, and observable |
| Duplicate webhook deliveries | Same event arrives twice | Unique constraint on stripeEventId |
| Out-of-order events | Update arrives before create | Upsert by Stripe IDs, not by local assumptions |
| Missing customer mapping | Subscription exists but no user | Use metadata userId, and reconcile with a backfill job |
| Plan mismatch | Price ID not in config | Default to no access and alert |
| Payment fails | Subscription becomes past_due | Decide grace period; drive to Billing Portal |
| App calls Stripe on every request | Latency and rate limits | Resolve entitlements from DB, refresh via webhooks |
Observability requirements for billing#
At minimum, log:
- Stripe
event.id,event.type, and processing time - Database write outcomes
- “Unknown Price ID” incidents
- “No user for stripeCustomerId” incidents
Then expose metrics:
- Webhook processing success rate
- Average webhook processing latency
- Count of users in
past_duestate - Count of webhook retries
If you want a practical setup for logs, metrics, and tracing, use our observability guide.
# Step 8: Security Considerations (Production Checklist)#
Billing touches identity, money, and access control. Treat it as a security-critical surface.
Key security rules#
| Risk | Bad outcome | Mitigation |
|---|---|---|
| Unverified webhooks | Attacker grants themselves access | Always validate Stripe signature |
| Client-trusted status | Users bypass paywall | Gate access on server from DB |
| Leaked secret keys | Full billing compromise | Use server-only env vars, rotate keys |
| Over-permissive Portal | Unintended plan changes | Restrict Portal configuration |
| Metadata tampering | Wrong user mapping | Never trust client metadata alone; verify customer ownership |
| SSRF and injection | Lateral movement | Validate inputs; least privilege |
You should also run a broader review against our web application security checklist, especially around secrets handling and authorization boundaries.
⚠️ Warning: Do not expose Stripe secret keys in Next.js public env variables. Anything prefixed with
NEXT_PUBLIC_can reach the browser.
# Step 9: Production Hardening Patterns#
Once the basics work, these patterns remove the last 10 percent of billing pain.
1) Reconciliation job#
Webhooks can fail due to temporary outages. Run a daily job to reconcile:
- Query users with active-looking states
- Fetch Stripe subscription by stored ID
- Repair discrepancies
Keep it rate-limit friendly by batching and only fetching recent changes.
2) Entitlement snapshots for ultra-fast reads#
If every request hits entitlements and your DB is hot, store a EntitlementSnapshot JSON updated by webhook processing.
This makes edge middleware and caching strategies easier, because you can read a single row.
3) Admin tooling#
Give support a safe way to:
- View Stripe customer link
- View current status and renewal date
- Trigger “resync from Stripe” for one user
This reduces “we can’t reproduce it” cycles and lowers support time per ticket.
# Key Takeaways#
- Use Stripe Checkout for purchase and Billing Portal for ongoing changes; avoid building your own plan management UI unless you truly need it.
- Verify webhooks using the raw request body, process them idempotently via a
stripeEventIdunique constraint, and treat your database as the access source of truth. - Normalize Stripe subscription data into a small local model, then compute entitlements from
status,priceId, and period dates. - Test locally with Stripe CLI for signature verification, retries, duplicates, and out-of-order events before you ship.
- Design for failure: reconciliation jobs, alerting on unmapped prices, and clear handling of
past_dueplus cancellation at period end. - Apply security basics: secret key hygiene, strict authorization, and never trusting client-side billing state.
# Conclusion#
A solid Next.js Stripe subscriptions implementation is mostly about correctness under real-world conditions: retries, partial failures, and state changes you didn’t trigger directly. If you anchor access control in your database, keep webhook handling verified and idempotent, and map subscriptions to explicit entitlements, you’ll avoid the most expensive billing bugs.
If you want Samioda to review your billing architecture or implement a full subscription system with observability and security hardening, contact us and we’ll help you ship a production-ready setup faster.
FAQ
More in Web Development
All →Next.js Caching Strategies Explained: SSR, SSG, ISR, Route Cache, and SWR
A practical guide to Next.js caching strategies in the App Router era — how SSR, SSG, ISR, the Route Cache, Data Cache, and SWR fit together, with decision tables, code examples, and common pitfalls like stale auth and tenant data.
Next.js Multitenant SaaS Architecture: Tenancy Models, Routing, Auth, and Data Isolation (2026 Guide)
A practical guide to Next.js multitenant SaaS architecture: tenancy models, tenant-aware routing with App Router and middleware, auth patterns, and hardening data isolation to prevent leaks.
Web Application Observability: A Practical Guide to Logging, Metrics, and Tracing for React and Next.js
An end-to-end, production-ready observability setup for React and Next.js: error tracking, performance monitoring, structured logs, tracing, dashboards, and alerts that catch real issues.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
Next.js Multitenant SaaS Architecture: Tenancy Models, Routing, Auth, and Data Isolation (2026 Guide)
A practical guide to Next.js multitenant SaaS architecture: tenancy models, tenant-aware routing with App Router and middleware, auth patterns, and hardening data isolation to prevent leaks.
Next.js Authentication in 2026: NextAuth vs Clerk vs Supabase (What We Use for Client Projects)
A practical comparison of Next.js authentication options in 2026 — NextAuth, Clerk, and Supabase — across UX, security, cost, setup time, and enterprise requirements, with decision matrices for SaaS, internal tools, and B2B portals.
Next.js Caching Strategies Explained: SSR, SSG, ISR, Route Cache, and SWR
A practical guide to Next.js caching strategies in the App Router era — how SSR, SSG, ISR, the Route Cache, Data Cache, and SWR fit together, with decision tables, code examples, and common pitfalls like stale auth and tenant data.