Subscription Models
Subscriptions are recurring payments, like a gym membership for your app. Users pay monthly or yearly; you get predictable revenue. Stripe Billing runs the schedule, and your job is to keep your database in sync with it.
How Subscriptions Work
Think of it like Netflix: user signs up → picks a plan → enters payment → gets billed automatically every cycle. Stripe manages the lifecycle:
Checkout
User picks a plan and pays
Billing Cycle
Auto-charged monthly or yearly
Customer Portal
Users self-serve cancel, switch, update card
Stripe Subscription Concepts
Products
What you're selling. Example: a "Builder" plan. Created once in the Stripe Dashboard.
Prices
How much and how often. One product can have several prices, say $19/month and $149/year. Each has a unique ID (price_...).
Subscriptions
The billing relationship between a Customer and a Price. Has a status: trialing, active, past_due, canceled, and a few more.
Customers
Stripe's record of a paying user. Store the stripe_customer_id in your database next to your user so you can find them again (and open their portal).
The Subscription Flow
User clicks “Subscribe”
Your server (not the browser) picks the Price ID and creates a Checkout Session in subscription mode.
Stripe Checkout handles payment
User enters card details on Stripe's hosted page. You never touch card numbers.
Webhook confirms success
Stripe sends checkout.session.completed. You save the customer ID and subscription status in your database.
Access granted
Your app checks the stored status (active or trialing) before showing premium features.
Renewals and changes
Stripe charges each cycle and sends invoice.paid, invoice.payment_failed, and customer.subscription.updated / .deleted. Your webhook updates the row.
What to Store in Your Database
A few columns on your users table (or a small subscriptions table) is enough. Stripe stays the source of truth; your copy is a fast cache that webhooks keep fresh. More in Schema Design and Drizzle.
lib/db/schema.ts (Drizzle)
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: text("id").primaryKey(),
email: text("email").notNull().unique(),
stripeCustomerId: text("stripe_customer_id").unique(),
stripeSubscriptionId: text("stripe_subscription_id"),
subscriptionStatus: text("subscription_status"), // "active", "trialing", "past_due", ...
currentPriceId: text("current_price_id"),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
});Creating a Subscription Checkout
The user ID comes from your auth Authentication Verifying WHO you are, usually by logging in with email and password, a magic link, or OAuth with Google/GitHub. Libraries like Auth.js, Better Auth, and Clerk handle the hard parts. "Like showing your ID at the door. Proving you are who you claim to be."lib/stripe.ts is set up in the Payments guide; auth() is from Auth.js, see Auth.)
app/api/stripe/checkout/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { stripe } from "@/lib/stripe";
const PRICES: Record<string, string | undefined> = {
monthly: process.env.STRIPE_PRICE_MONTHLY,
yearly: process.env.STRIPE_PRICE_YEARLY,
};
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Sign in first" }, { status: 401 });
}
const { plan } = await req.json();
const price = PRICES[plan];
if (!price) {
return NextResponse.json({ error: "Unknown plan" }, { status: 400 });
}
const origin = req.headers.get("origin") ?? "http://localhost:3000";
const checkout = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price, quantity: 1 }],
client_reference_id: session.user.id, // links the payment to your user
customer_email: session.user.email ?? undefined,
success_url: `${origin}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/pricing`,
});
return NextResponse.json({ url: checkout.url });
}The client_reference_id trick
When checkout.session.completed arrives, read session.client_reference_id to know which user to update, and save session.customer and session.subscription onto that row. Returning subscriber? Pass customer: user.stripeCustomerId instead of customer_email.
Customer Portal: Skip Building Billing UI
Configure what users can do (cancel, switch plans, update cards, download invoices) under Dashboard settings → Billing → Customer portal, once for your sandbox and once for live mode. Then add a "Manage billing" button that creates a short-lived portal session:
app/api/stripe/portal/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { stripe } from "@/lib/stripe";
import { getUserById } from "@/lib/users"; // your own DB helper
export async function POST(req: Request) {
const session = await auth();
const user = session?.user?.id ? await getUserById(session.user.id) : null;
if (!user?.stripeCustomerId) {
return NextResponse.json({ error: "No billing account" }, { status: 400 });
}
const origin = req.headers.get("origin") ?? "http://localhost:3000";
const portal = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${origin}/dashboard`,
});
return NextResponse.redirect(portal.url, 303);
}Always authenticate before creating a portal session. Anyone holding the URL can manage that customer's billing until it expires.
Upgrades & Downgrades
Easiest path: let users switch plans in the Customer Portal and react to customer.subscription.updated. If you build your own plan switcher, you update the subscription item's price:
// Switch a subscription to a new price
await stripe.subscriptions.update(subscriptionId, {
items: [{ id: existingItemId, price: newPriceId }],
proration_behavior: "create_prorations", // or "always_invoice" / "none"
});What proration means
With create_prorations, Stripe credits unused time on the old plan and charges for the rest of the period on the new one, usually on the next invoice. always_invoice bills the difference right away. Want a downgrade to wait until renewal? Read Stripe's docs on subscription schedules before building it.
Trial Periods
Let users try before they buy. Stripe won't charge until the trial ends.
// Add a 7-day trial to checkout
const checkout = await stripe.checkout.sessions.create({
mode: "subscription",
subscription_data: { trial_period_days: 7 },
// ...line_items, client_reference_id, success_url, cancel_url
});Trial Status
During a trial the subscription status is trialing. Grant access for both active and trialing.
Common Pitfalls
Checking payment at checkout only
Subscriptions fail, cancel, and expire. Checking only at signup misses all of that.
Fix: Handle subscription webhooks and gate features on the stored status, every request.
Trusting the plan or user ID from the browser
If the client sends priceId and userId, anyone can subscribe someone else or pick a secret $0 price.
Fix: Map a plan name to an allowlisted Price ID on the server, and take the user from the auth session.
Not handling failed payments
Cards expire, get declined, or hit limits. The subscription goes past_due.
Fix: Listen for invoice.payment_failed and nudge the user to update their card (a portal link is perfect). Stripe can retry failed payments automatically; check your Billing settings.
Proration surprises
A mid-cycle upgrade produces a charge the user didn't expect (or a downgrade gives away credit).
Fix: Pick a proration_behavior on purpose and show users what they'll pay before they confirm.
Sandbox keys in production
sk_test_ keys don't process real payments, and sandbox webhooks have their own signing secret.
Fix: Use sk_live_ and the live whsec_ only in Vercel's Production env vars. Configure the portal in live mode too.
Handle subscription events
Webhooks tell you when subscriptions change. Learn to handle them reliably.