DEV Community

huangyongshan46-a11y
huangyongshan46-a11y

Posted on • Edited on • Originally published at dev.to

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)