Subscription billing is one of the trickiest parts of building a SaaS product — not because Stripe is hard to use, but because there are multiple moving parts that have to stay in sync: the checkout session, the webhook, your database, and the customer portal. This guide walks through the complete subscription billing flow in Next.js, from creating your first Stripe product to handling edge cases like failed payments and cancellations.
Subscription vs one-time payment
Stripe supports two primary payment models. A one-time payment charges a customer once and fulfills the order immediately. A subscription charges the customer on a recurring schedule (monthly, annually, etc.) and grants access for as long as the subscription remains active.
Subscriptions introduce state that one-time payments don't have: active, past_due, canceled, unpaid, and trialing. Your application has to check this state on every request to a protected resource — a customer who cancels should lose access immediately (or at the end of their billing period, depending on your policy).
Creating a product and recurring price in Stripe
Before writing any code, create your product in the Stripe dashboard (or via the API). A product represents what you're selling; a price represents how much and how often.
// Using the Stripe CLI or dashboard, or via API:
const product = await stripe.products.create({
name: "Pro Plan",
description: "Full access to all features",
});
const price = await stripe.prices.create({
product: product.id,
unit_amount: 2900, // $29.00 in cents
currency: "usd",
recurring: {
interval: "month",
},
});
console.log("Price ID:", price.id);
// Copy this to your STRIPE_PRICE_ID env var
Store the price ID in your environment variables as STRIPE_PRICE_ID. You'll reference it when creating checkout sessions. Never hardcode price IDs in your application code.
Embedded checkout vs redirect checkout
Stripe offers two checkout experiences: redirect checkout, which sends the user to a Stripe-hosted page at checkout.stripe.com, and embedded checkout, which renders the payment form inside your own page using @stripe/react-stripe-js.
Embedded checkout has better UX: users never leave your domain, your branding stays consistent, and there's no jarring redirect. It also gives you more control over the layout. The tradeoff is slightly more setup code — but it's worth it for a production product.
For redirect checkout, you create a session and redirect to session.url. For embedded checkout, you create a session with ui_mode: “embedded” and use session.client_secret on the client.
The checkout session route
Create a POST route that creates a Stripe Checkout Session. The user calls this from the client; you redirect them to (or embed) the returned URL.
// app/api/stripe/checkout/route.ts
import Stripe from "stripe";
import { auth, currentUser } from "@clerk/nextjs/server";
import { adminClient } from "@/lib/supabase/admin";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST() {
const { userId } = await auth();
if (!userId) return new Response("Unauthorized", { status: 401 });
const user = await currentUser();
const email = user?.emailAddresses[0]?.emailAddress ?? "";
// Look up or create a Stripe customer ID for this user
const { data: sub } = await adminClient
.from("subscriptions")
.select("stripe_customer_id")
.eq("user_id", userId)
.maybeSingle();
let customerId = sub?.stripe_customer_id;
if (!customerId) {
const customer = await stripe.customers.create({ email, metadata: { clerk_id: userId } });
customerId = customer.id;
}
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
line_items: [{ price: process.env.STRIPE_PRICE_ID!, quantity: 1 }],
ui_mode: "embedded",
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?checkout=success`,
});
return Response.json({ clientSecret: session.client_secret });
}
The webhook → database flow
The checkout session completing does not mean the payment succeeded — that comes through the webhook. Your application should never grant access based on the redirect URL alone. The only reliable signal is the webhook event.
Set up a webhook handler at /api/stripe/webhook. The critical events for subscriptions are:
checkout.session.completed — user finished checkout; subscription is now activecustomer.subscription.updated — plan changed, trial ended, payment method updatedcustomer.subscription.deleted — subscription canceled at period end or immediatelyinvoice.payment_failed — payment failed; Stripe will retry, but you should notify the user
// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
import { adminClient } from "@/lib/supabase/admin";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response("Invalid signature", { status: 400 });
}
if (event.type === "customer.subscription.updated" ||
event.type === "customer.subscription.deleted") {
const sub = event.data.object as Stripe.Subscription;
await adminClient.from("subscriptions").upsert({
stripe_customer_id: sub.customer as string,
stripe_subscription_id: sub.id,
status: sub.status,
plan: sub.items.data[0]?.price.lookup_key ?? "pro",
current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
}, { onConflict: "stripe_customer_id" });
}
return new Response(null, { status: 200 });
}
Managing subscription state
Store the subscription status in your database and check it on every protected API request. The statuses you care most about:
active — the subscription is current; grant full accesstrialing — on a free trial; grant full access (or a limited version, depending on your product)past_due — payment failed but Stripe is retrying; you may want to show a payment warning banner but keep access for a grace periodcanceled — subscription ended; revoke access immediately or at the end of current_period_end
// lib/subscription.ts
import { adminClient } from "@/lib/supabase/admin";
export async function getSubscription(userId: string) {
const { data } = await adminClient
.from("subscriptions")
.select("status, plan, current_period_end, stripe_customer_id")
.eq("user_id", userId)
.maybeSingle();
return data;
}
export function isSubscriptionActive(status: string | null): boolean {
return status === "active" || status === "trialing";
}
The customer portal
Never build your own subscription management UI. Stripe's Customer Portal handles plan changes, payment method updates, invoice history, and cancellation — all with Stripe's own battle-tested UI.
// app/api/stripe/portal/route.ts
import Stripe from "stripe";
import { auth } from "@clerk/nextjs/server";
import { adminClient } from "@/lib/supabase/admin";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST() {
const { userId } = await auth();
if (!userId) return new Response("Unauthorized", { status: 401 });
const { data: sub } = await adminClient
.from("subscriptions")
.select("stripe_customer_id")
.eq("user_id", userId)
.maybeSingle();
if (!sub?.stripe_customer_id) {
return new Response("No subscription found", { status: 404 });
}
const session = await stripe.billingPortal.sessions.create({
customer: sub.stripe_customer_id,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing`,
});
return Response.json({ url: session.url });
}
On the client, call this endpoint and redirect the user to the returned URL. The portal session is short-lived and user-specific — never store or reuse the URL.
Testing with Stripe test mode and the webhook CLI
Stripe has a full test mode with its own API keys and test card numbers. Use 4242 4242 4242 4242 with any future expiry and any CVC to simulate a successful payment. Use 4000 0000 0000 9995 to simulate a declined card.
To test webhooks locally, install the Stripe CLI and forward events to your dev server:
# Install Stripe CLI (macOS)
brew install stripe/stripe-cli/stripe
# Log in
stripe login
# Forward webhook events to your local server
stripe listen --forward-to localhost:3000/api/stripe/webhook
# The CLI will print your webhook signing secret:
# > Ready! Your webhook signing secret is whsec_test_...
# Add this to .env.local as STRIPE_WEBHOOK_SECRET
With the CLI running, trigger specific events manually to test your handler:
# Simulate a completed checkout
stripe trigger checkout.session.completed
# Simulate a canceled subscription
stripe trigger customer.subscription.deleted
Always test the full webhook flow — including the database write — before shipping. A missing onConflict clause or wrong column name will silently fail in production, leaving users stuck in a bad state with no error surfaced.
The complete subscription billing pipeline — checkout session, webhook handler, customer portal, and database upsert — is pre-built and production-ready in GetLaunchpad, a Next.js 16 SaaS boilerplate. Skip the wiring and start building your product today.