DEV Community

huangyongshan46-a11y
huangyongshan46-a11y

Posted on

How to Set Up Stripe Subscriptions in Next.js 16 (Complete Guide)

Setting up Stripe subscriptions in Next.js is one of those tasks that sounds simple but has a dozen gotchas. After implementing it across multiple SaaS projects, here's the complete, production-ready approach.

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-js
Enter fullscreen mode Exit fullscreen mode

2. 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 },
  },
};
Enter fullscreen mode Exit fullscreen mode

3. Create the checkout API route

This creates a Stripe Checkout session and redirects the user:

// 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 });
}
Enter fullscreen mode Exit fullscreen mode

4. Handle webhooks (the critical part)

This is where most tutorials stop. But webhooks are what actually 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;
      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;
      await db.subscription.update({
        where: { stripeSubscriptionId: subscription.id },
        data: { status: "CANCELED", plan: "FREE" },
      });
      break;
    }
  }

  return NextResponse.json({ received: true });
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

Common gotchas

  1. Webhook signature verification fails locally — Use stripe listen --forward-to localhost:3000/api/stripe/webhook during development
  2. Subscription status gets out of sync — Always trust webhooks over client state
  3. Missing metadata — Always pass userId in checkout session metadata
  4. Not handling cancellations — Users 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.

GitHub | Get LaunchKit ($49)

Top comments (0)