How to Set Up Stripe Subscriptions in Next.js 16 (Complete Guide)
Source: Dev.to
What we’re building
- Stripe Checkout for new subscriptions
- Webhook handling for payment events
- Plan management with free / pro / enterprise tiers
- Customer portal for self‑service billing
1️⃣ Install dependencies
npm install stripe @stripe/stripe-js2️⃣ Define your plans
Create a central config for your plans. This is the source of truth for features and limits.
// src/lib/stripe.ts
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export const PLANS = {
free: {
name: "Free",
price: { monthly: 0 },
features: ["Up to 3 projects", "Basic analytics", "Community support"],
limits: { projects: 3, aiMessages: 50 },
},
pro: {
name: "Pro",
price: { monthly: 29 },
stripePriceId: process.env.STRIPE_PRO_PRICE_ID,
features: [
"Unlimited projects",
"Advanced analytics",
"Priority support",
"AI assistant",
],
limits: { projects: -1, aiMessages: 1000 },
},
enterprise: {
name: "Enterprise",
price: { monthly: 99 },
stripePriceId: process.env.STRIPE_ENTERPRISE_PRICE_ID,
features: [
"Everything in Pro",
"SSO/SAML",
"Unlimited AI",
"SLA guarantee",
],
limits: { projects: -1, aiMessages: -1 },
},
};3️⃣ Create the checkout API route
This creates a Stripe Checkout session and returns the redirect URL.
// src/app/api/stripe/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { auth } from "@/lib/auth";
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { priceId } = await req.json();
const checkoutSession = 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}/billing?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing?canceled=true`,
metadata: { userId: session.user.id },
});
return NextResponse.json({ url: checkoutSession.url });
}4️⃣ Handle webhooks (the critical part)
Webhooks keep your database in sync with Stripe.
// src/app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get("stripe-signature")!;
let event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
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 as any;
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
);
await db.subscription.upsert({
where: { userId: session.metadata.userId },
create: {
userId: session.metadata.userId,
stripeCustomerId: session.customer as string,
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id,
status: "ACTIVE",
plan: "PRO",
},
update: {
stripeSubscriptionId: subscription.id,
status: "ACTIVE",
plan: "PRO",
},
});
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as any;
await db.subscription.update({
where: { stripeSubscriptionId: subscription.id },
data: { status: "CANCELED", plan: "FREE" },
});
break;
}
}
return NextResponse.json({ received: true });
}5️⃣ The billing page
Show users their current plan and let them upgrade.
// Client‑side upgrade button
async function handleUpgrade(priceId: string) {
const res = await fetch("/api/stripe/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ priceId }),
});
const { url } = await res.json();
window.location.href = url;
}6️⃣ Protect features by plan
// Middleware or server component
const subscription = await db.subscription.findUnique({
where: { userId: session.user.id },
});
if (
subscription?.plan !== "PRO" &&
subscription?.plan !== "ENTERPRISE"
) {
redirect("/billing");
}Common gotchas
- Webhook signature verification fails locally – Use
stripe listen --forward-to localhost:3000/api/stripe/webhookduring development. - Subscription status gets out of sync – Always trust webhooks over client‑side state.
- Missing metadata – Always pass
userIdin the checkout session metadata. - Not handling cancellations – Ensure you process
customer.subscription.deletedevents.
Cancel. Handle customer.subscription.deleted
Want the full implementation?
If you want all of this pre‑wired with Auth.js v5, Prisma, AI chat, email, and a beautiful UI, check out LaunchKit — it’s a production‑ready SaaS starter kit with everything connected.
