How to add Stripe subscriptions to a Next.js app with Cursor
By VibeBoot ·
Adding subscriptions is the step where most AI-built SaaS projects stall. The code itself isn't long, but there are four moving parts — Checkout, the webhook, your database and the Customer Portal — and an AI agent will happily get one of them subtly wrong. This guide shows the architecture that works, the code for each piece in a Next.js App Router project, and the prompts that get Cursor to build it correctly.
The architecture in one paragraph
Your app never decides whether someone is subscribed. Stripe is the source of truth. The user clicks "Upgrade", your server creates a Stripe Checkout Session and redirects them to it. After payment, Stripe sends events to your webhook. The webhook verifies the signature and writes the subscription status to your database. Your pages read that status from the database to gate paid features. To cancel or change cards, users go to the Stripe Customer Portal, and any changes come back through the same webhook.
What you need first
- A Next.js App Router project with authentication (you need a user id to attach the subscription to).
- A database with fields on the user like
stripeCustomerId,stripeSubscriptionId,subscriptionStatus. - A Stripe account in test mode, a recurring Price (copy its
price_...id) and the Stripe CLI. npm install stripe
Environment variables:
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PRO_PRICE_ID=price_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_APP_URL=http://localhost:3000Step 1: a lazy Stripe client
Create the client inside a function, not at module top level. next build evaluates modules without your secrets, and a top-level new Stripe(undefined) breaks the build — a mistake agents make constantly.
// src/lib/stripe.ts
import Stripe from "stripe";
let stripe: Stripe | null = null;
export function getStripe() {
if (!stripe) stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
return stripe;
}
export function isSubscribed(status?: string | null) {
return status === "active" || status === "trialing";
}Step 2: the Checkout route
// src/app/api/stripe/checkout/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { getStripe } from "@/lib/stripe";
export async function POST() {
const session = await auth();
if (!session?.user?.id) return NextResponse.redirect(new URL("/login", process.env.NEXT_PUBLIC_APP_URL));
const checkout = await getStripe().checkout.sessions.create({
mode: "subscription",
line_items: [{ price: process.env.STRIPE_PRO_PRICE_ID!, quantity: 1 }],
client_reference_id: session.user.id,
customer_email: session.user.email ?? undefined,
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?upgraded=1`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
});
return NextResponse.redirect(checkout.url!, 303);
}client_reference_id is how the webhook later knows which of your users paid. If the user already has a stripeCustomerId, pass customer instead of customer_email so you don't create duplicate customers.
Step 3: the webhook (the part that matters)
Two rules: read the raw body with await req.text() (parsing it as JSON breaks the signature), and verify it with constructEvent.
// src/app/api/stripe/webhook/route.ts
import type Stripe from "stripe";
import { getStripe } from "@/lib/stripe";
import { db } from "@/lib/db";
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = getStripe().webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response("Invalid signature", { status: 400 });
}
switch (event.type) {
case "checkout.session.completed": {
const s = event.data.object;
if (s.client_reference_id) {
await db.user.update({
where: { id: s.client_reference_id },
data: { stripeCustomerId: s.customer as string, stripeSubscriptionId: s.subscription as string },
});
}
break;
}
case "customer.subscription.created":
case "customer.subscription.updated":
case "customer.subscription.deleted": {
const sub = event.data.object;
await db.user.updateMany({
where: { stripeCustomerId: sub.customer as string },
data: { stripeSubscriptionId: sub.id, subscriptionStatus: sub.status },
});
break;
}
}
return new Response("ok");
}Keep handlers idempotent: Stripe retries and can deliver events more than once or out of order. Writing the current status (rather than incrementing or toggling anything) makes repeats harmless.
Test it locally:
stripe listen --forward-to localhost:3000/api/stripe/webhook
stripe trigger checkout.session.completedPaste the whsec_... that stripe listen prints into STRIPE_WEBHOOK_SECRET.
Step 4: the Customer Portal
Turn it on in the Stripe Dashboard (Settings → Billing → Customer portal), then:
// src/app/api/stripe/portal/route.ts
const portal = await getStripe().billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
});
return NextResponse.redirect(portal.url, 303);That gives you cancellation, card updates and invoices for free — don't build those screens yourself.
Step 5: gate features on the server
const user = await db.user.findUnique({ where: { id: session.user.id } });
if (!isSubscribed(user?.subscriptionStatus)) redirect("/pricing");Never trust a flag sent from the browser, and never set subscriptionStatus anywhere except the webhook.
Prompts that work in Cursor
Ask for a plan first, then implement piece by piece:
- "Plan how to add Stripe subscriptions to this app: Checkout, a verified webhook, Customer Portal, and subscription fields on User. Don't write code yet."
- "Implement the lazy Stripe client and the checkout route from the plan. Don't instantiate Stripe at module top level."
- "Implement the webhook. Read the raw body with req.text(), verify with constructEvent, handle checkout.session.completed and customer.subscription.created/updated/deleted idempotently."
- "Add a Manage billing button that opens the Stripe Customer Portal."
- "Run npm run lint and npm run build and fix any errors."
Even better, put the rules in a .cursor/rules/stripe.mdc file scoped to your billing files so the agent follows them every time — see Best Cursor rules for a Next.js SaaS.
Going to production
- Add a webhook endpoint in the Stripe Dashboard at
https://your-domain.com/api/stripe/webhookwith the four events above, and use its signing secret (it differs from the CLI one). - Use a real database (Postgres) — SQLite files don't persist on serverless hosts like Vercel.
- Switch to live keys and a live Price id only when everything works in test mode.
Common mistakes
- Parsing the webhook body with
req.json()→ every signature check fails. - Creating the Stripe client at import time →
next buildfails without keys. - Granting access on the
success_urlredirect instead of the webhook → users can fake it, and cancellations never revoke access. - Forgetting to activate the Customer Portal in the Dashboard → the portal call errors.