TL;DR — What This Guide Covers
Why Stripe Integration Is Harder Than It Looks
The Stripe docs are excellent. The quickstart really does get you a working checkout in 15 minutes. And that's exactly the problem — it works well enough that you ship it, then discover six months later that your billing system is quietly broken in production.
The most common mistakes I see when auditing codebases:
- Treating checkout as "just a link" — redirecting users to Stripe and assuming the redirect back means they paid. It doesn't. A user can close the browser mid-session. The checkout.session.completed webhook is the only reliable signal.
- Missing webhook handling entirely — the redirect success URL is a UX convenience, not a payment confirmation. Relying on it means your access provisioning is race-prone and untrustworthy.
- Not syncing subscription state to your DB — querying Stripe's API on every request to check subscription status is slow and fragile. Write subscription data to your own database on every webhook event.
- Skipping webhook signature verification — without verifying the Stripe-Signature header, any attacker can POST fake events to your webhook endpoint and provision themselves free access.
- Handling only checkout.session.completed — subscriptions update and cancel asynchronously. A user can dispute a charge, cancel from the Stripe portal, or have a card decline. None of these trigger a new checkout. They all arrive as subscription webhooks.
Most tutorials cover step one — checkout — and leave the rest as an exercise for the reader. This guide covers all of it.
The Architecture You Need
Before writing any code, understand the full data flow. Every part of this chain matters, and a missing link causes silent failures.
User clicks "Subscribe" → Your API creates a Stripe Checkout Session → User is redirected to Stripe-hosted checkout page → User completes payment on Stripe → Stripe sends checkout.session.completed webhook to your API → Your webhook handler verifies signature + writes to your DB → Stripe sends customer.subscription.* events on any status change → Your webhook handler keeps DB in sync on every event → Next.js middleware reads subscription status from DB → Protected routes (/dashboard, /app) are gated by middleware → User accesses protected content
The key insight: Stripe is the source of truth for payment state, but your database is the source of truth for your application. Every Stripe event must be durably written to your DB. Your application never queries Stripe at runtime — it reads from its own database, which webhooks keep in sync.
This separation is what makes billing reliable at scale. Stripe's API has SLAs, but adding a round-trip to every page load is unnecessary latency you control by maintaining local state.
Step 1: Setup
Install the Stripe Node SDK and the browser-side Stripe.js package:
npm install stripe @stripe/stripe-js
Add your environment variables. You need three:
# .env.local STRIPE_SECRET_KEY=sk_live_... # Never expose to client STRIPE_WEBHOOK_SECRET=whsec_... # From Stripe Dashboard → Webhooks NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_... # Safe for client bundle
Create a singleton Stripe instance. Instantiating it per-request is wasteful; the singleton pattern ensures one connection is reused across server-side calls:
// lib/stripe.ts
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-11-20',
typescript: true,
});The typescript: true flag enables Stripe's TypeScript-aware types for all SDK responses. Always pin the apiVersion explicitly so Stripe API updates don't silently break your integration.
Step 2: Create Products and Price IDs
Create your products and pricing plans in the Stripe Dashboard (or via the API). Each plan gets a Price ID — a string that looks like price_1OAbc123XYZ. This ID is what you pass to the checkout session to tell Stripe what the user is buying.
Store price IDs as environment variables, not hardcoded strings. This lets you swap between test and live prices without code changes:
# .env.local STRIPE_PRICE_BASE=price_1OAbc123XYZ_base STRIPE_PRICE_PRO=price_1OAbc123XYZ_pro STRIPE_PRICE_ENTERPRISE=price_1OAbc123XYZ_enterprise
Use test mode prices (price_test_...) in development and live prices in production. Your Stripe Dashboard will have separate test and live environments — always develop and test in test mode before going live.
Step 3: Checkout Session API Route
Create a server-side API route that generates a Stripe Checkout Session. This runs on your server — never call Stripe with your secret key from the client.
// app/api/checkout/route.ts
import { stripe } from '@/lib/stripe';
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
const { priceId, userId, email } = await req.json();
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
customer_email: email,
metadata: { userId },
});
return NextResponse.json({ url: session.url });
}Two things to note here. First, metadata: { userId } — this is how you tie a Stripe payment back to a user in your system. Stripe passes this metadata through to every related webhook event. Without it, you receive a checkout.session.completed event with no way to know which user in your DB should get access. Always include your internal user ID in metadata.
Second, the success_url is purely cosmetic for UX. Do not use the ?success=true query param to provision access. Provision access in the webhook handler only.
On the client side, redirect the user to the returned session URL:
// In your pricing component (client component)
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId, userId, email }),
});
const { url } = await res.json();
window.location.href = url; // Redirect to StripeStep 4: Webhook Handler — The Critical Part
Webhooks are where most Stripe integrations break down. They are also the single most important part of your billing system. Get this wrong and you have a product that silently fails to provision or revoke access.
The three events you must handle for a subscription product:
- checkout.session.completed — fires when a user completes checkout. This is when you first grant access.
- customer.subscription.updated — fires when a subscription changes status: trial ending, payment retry, plan upgrade, reactivation. Keep your DB in sync.
- customer.subscription.deleted — fires when a subscription is cancelled (immediately or at period end). Revoke access.
Here is the full webhook route with proper signature verification:
// app/api/webhooks/stripe/route.ts
import { stripe } from '@/lib/stripe';
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
const body = await req.text(); // Raw body — CRITICAL (see mistake #1 below)
const sig = headers().get('stripe-signature')!;
let event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 400 }
);
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
await syncSubscriptionToDb(session.subscription as string, session.metadata?.userId);
break;
}
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
const subscription = event.data.object;
await syncSubscriptionToDb(subscription.id, subscription.metadata?.userId);
break;
}
}
return NextResponse.json({ received: true });
}
// CRITICAL: Disable Next.js body parsing so we get the raw bytes
// Stripe signature verification requires the exact raw request body
export const config = { api: { bodyParser: false } };The constructEvent call verifies that the webhook actually came from Stripe using your webhook secret. If verification fails — wrong secret, tampered body, replayed request — it throws and you return a 400. This is your security gate.
Step 5: Sync Subscription State to Your DB
After every relevant webhook, write the subscription's current state to your database. This is the function your webhook handler calls. The example below uses Firestore, but the pattern applies equally to any database.
// lib/syncSubscription.ts
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/firebase-admin'; // your DB client
export async function syncSubscriptionToDb(
subscriptionId: string,
userId?: string | null
) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
// Resolve userId from subscription metadata if not passed directly
const resolvedUserId = userId ?? subscription.metadata?.userId;
if (!resolvedUserId) {
console.error('syncSubscriptionToDb: no userId found', subscriptionId);
return;
}
const subscriptionData = {
subscriptionId: subscription.id,
customerId: subscription.customer as string,
status: subscription.status, // 'active' | 'canceled' | 'past_due' | ...
priceId: subscription.items.data[0].price.id,
currentPeriodEnd: subscription.current_period_end,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
updatedAt: Date.now(),
};
// Write to your DB — idempotent upsert
await db.collection('users').doc(resolvedUserId).update({
subscription: subscriptionData,
});
}The key fields to store: status, currentPeriodEnd, and cancelAtPeriodEnd. Your middleware and UI will read these directly. The status field is the primary gate — only 'active' and 'trialing' users should have access to paid features.
Note that this function fetches the full subscription from Stripe on each call. This is intentional — it ensures you always have the latest state, not a snapshot from the event payload which may have been queued.
Step 6: Protect Routes with Middleware
Next.js middleware runs before every request matching your matcher pattern. This is where you enforce subscription-based access control — read the user's subscription status from your DB (or a session cookie) and redirect unauthenticated or unpaid users before the page renders.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { getSubscriptionStatus } from '@/lib/auth-helpers';
export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
// Get user session from your auth layer (Firebase, NextAuth, etc.)
const userId = req.cookies.get('userId')?.value;
if (!userId) {
return NextResponse.redirect(new URL('/login', req.url));
}
// Check subscription status stored in your DB
const status = await getSubscriptionStatus(userId);
const hasAccess = status === 'active' || status === 'trialing';
if (!hasAccess) {
return NextResponse.redirect(new URL('/pricing?reason=subscription', req.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/app/:path*'],
};A critical performance note: middleware runs on every request. getSubscriptionStatus must be fast — read from a cache or session token, not a live DB call. One pattern is to store subscription status in a signed session cookie on login, and refresh it on webhook events. Another is to read from an edge-compatible KV store like Upstash Redis.
Step 7: Customer Portal
Once users are subscribed, they need a way to manage their billing — upgrade plans, update payment methods, cancel, or view invoices. Stripe's Customer Portal handles all of this without you building any UI.
Create a portal session API route that redirects the user to Stripe's hosted portal:
// app/api/portal/route.ts
import { stripe } from '@/lib/stripe';
import { NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth-helpers';
export async function POST(req: Request) {
const user = await getCurrentUser(req);
if (!user?.customerId) {
return NextResponse.json({ error: 'Not subscribed' }, { status: 400 });
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.customerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing`,
});
return NextResponse.json({ url: portalSession.url });
}The Customer Portal must be configured in your Stripe Dashboard first — navigate to Settings → Billing → Customer Portal and enable the features you want users to access (cancel, upgrade, update payment method, view invoices). Any changes users make in the portal fire subscription webhooks that your handler already processes, keeping your DB in sync automatically.
The 3 Mistakes That Will Break Your Billing
Testing Locally
Stripe provides a CLI tool that forwards live Stripe webhook events to your local development server. Install it from stripe.com/docs/stripe-cli, log in, and run:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
This prints a webhook signing secret for your local session — paste it into your .env.local as STRIPE_WEBHOOK_SECRET. The CLI intercepts every event Stripe sends and forwards it to your local URL, including signature headers, so your verification code runs exactly as it would in production.
You can also trigger specific events manually:
stripe trigger checkout.session.completed stripe trigger customer.subscription.updated stripe trigger customer.subscription.deleted
For checkout testing, use Stripe's test card numbers. The most useful ones:
For all test cards, use any future expiry date (12/34 works), any 3-digit CVC, and any billing zip. Stripe accepts these unconditionally in test mode.
