Web Development
Next.jsStripeSubscriptionsPaymentsWebhooksSaaS

Implementing Stripe Subscriptions in Next.js: Billing Portal, Webhooks, and Entitlements

AO
Adrijan Omićević
·15 min read

# 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#

ConcernStripeYour app
Pricing (products, prices, trials)Define in Dashboard or via APIReference Price IDs in config
Customer identityCustomer objectMap userId to stripeCustomerId
Subscription lifecycleSubscription, Invoice, PaymentIntentPersist current status, renewal dates, plan
Self-serve managementBilling PortalProvide Portal session link
Access controlEntitlements derived from subscription record
Truth updatesWebhooksVerified + 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.

PlanStripe Price ID env varTrial daysExample entitlements
StarterSTRIPE_PRICE_STARTER7Basic features, 1 workspace
ProSTRIPE_PRICE_PRO14Advanced features, 5 workspaces
TeamSTRIPE_PRICE_TEAM14SSO, 20 workspaces, priority support

Define entitlements in code as a compact “capabilities object”. Keep it stable and versionable.

TypeScript
// 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.

TableKey fieldsWhy it matters
Userid, email, stripeCustomerIdStable mapping user to Stripe Customer
SubscriptionuserId, stripeSubscriptionId, status, priceId, currentPeriodEnd, cancelAtPeriodEndFast entitlement resolution
WebhookEventstripeEventId, type, processedAtIdempotency and audit trail
EntitlementSnapshot (optional)userId, json, updatedAtFast 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.

TypeScript
// 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_url based 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#

TypeScript
// 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 settingRecommendationWhy
Cancel subscriptionAllowReduces support tickets
Update payment methodAllowReduces failed renewal churn
Update planAllow, but restrict to your productPrevent mismatched prices
Invoice historyAllowFewer billing questions
ProrationDecide based on your businessAvoid 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:

EventUse it forNotes
checkout.session.completedLink user to customer, read subscription IDNot a guarantee of paid invoice for some flows
customer.subscription.createdCreate local subscription rowUseful when subscription is created outside Checkout
customer.subscription.updatedStatus changes, cancellations, plan swapsMost state changes flow through here
customer.subscription.deletedHard delete or ended subscriptionMark as canceled immediately
invoice.payment_succeededMark paid period, handle renewalsStrong signal subscription is funded
invoice.payment_failedDunning flows, restrict access policyDecide 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.

TypeScript
// 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 WebhookEvent row keyed by stripeEventId.
  • 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:

  1. 1
    Insert into WebhookEvent
  2. 2
    Upsert 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.

TypeScript
// 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 userId from stripeCustomerId (must be stored on user).
  • Upsert Subscription by stripeSubscriptionId.
  • Update User.stripeCustomerId when 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 status is active or trialing.
  • Optional grace period when status is past_due for less than N days.
  • No access when canceled, unpaid, or incomplete_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.

TypeScript
// 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 currentWorkspaceCount to entitlements.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#

Bash
stripe login
stripe --version

Forward webhooks to Next.js#

Run your app on http://localhost:3000, then:

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

Copy the webhook signing secret the CLI prints and set it as STRIPE_WEBHOOK_SECRET in your local env.

Trigger relevant events#

Bash
stripe trigger customer.subscription.created
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_succeeded
stripe trigger invoice.payment_failed

What a good test checklist looks like#

TestHowExpected result
Signature verificationSend a request without signatureEndpoint returns 400
IdempotencyRe-send same event IDNo duplicate DB writes
Out-of-order eventsTrigger update before createdUpsert still results in consistent row
Failure retryForce DB error onceStripe retries and eventually succeeds
Plan mappingUse different Price IDsCorrect planKey and entitlements
Cancellation behaviorSet cancel_at_period_endAccess 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 modeWhat happensMitigation
Webhook handler returns 500Stripe retries for hours or daysMake handler fast, transactional, and observable
Duplicate webhook deliveriesSame event arrives twiceUnique constraint on stripeEventId
Out-of-order eventsUpdate arrives before createUpsert by Stripe IDs, not by local assumptions
Missing customer mappingSubscription exists but no userUse metadata userId, and reconcile with a backfill job
Plan mismatchPrice ID not in configDefault to no access and alert
Payment failsSubscription becomes past_dueDecide grace period; drive to Billing Portal
App calls Stripe on every requestLatency and rate limitsResolve 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_due state
  • 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#

RiskBad outcomeMitigation
Unverified webhooksAttacker grants themselves accessAlways validate Stripe signature
Client-trusted statusUsers bypass paywallGate access on server from DB
Leaked secret keysFull billing compromiseUse server-only env vars, rotate keys
Over-permissive PortalUnintended plan changesRestrict Portal configuration
Metadata tamperingWrong user mappingNever trust client metadata alone; verify customer ownership
SSRF and injectionLateral movementValidate 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 stripeEventId unique 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_due plus 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

Share
A
Adrijan OmićevićSamioda Team
All articles →

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.