Skip to main content
Back to Blog
Tutorials
4 min read
November 25, 2024

How to Implement Stripe Subscription Management in Next.js

Build a complete Stripe subscription system with plan selection, customer portal, usage metering, and webhook handling.

Ryel Banfield

Founder & Lead Developer

Subscription billing is at the core of SaaS. Here is how to implement it with Stripe in Next.js.

Step 1: Setup

pnpm add stripe @stripe/stripe-js
// lib/stripe.ts
import Stripe from "stripe";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-12-18.acacia",
  typescript: true,
});

Step 2: Plan Configuration

// lib/plans.ts
export interface Plan {
  id: string;
  name: string;
  description: string;
  priceMonthly: number;
  priceYearly: number;
  stripePriceIdMonthly: string;
  stripePriceIdYearly: string;
  features: string[];
  limits: {
    projects: number;
    members: number;
    storage: number; // GB
  };
  popular?: boolean;
}

export const PLANS: Plan[] = [
  {
    id: "starter",
    name: "Starter",
    description: "For individuals and small projects",
    priceMonthly: 9,
    priceYearly: 90,
    stripePriceIdMonthly: "price_starter_monthly",
    stripePriceIdYearly: "price_starter_yearly",
    features: ["5 projects", "1 team member", "5 GB storage", "Email support"],
    limits: { projects: 5, members: 1, storage: 5 },
  },
  {
    id: "pro",
    name: "Pro",
    description: "For growing teams",
    priceMonthly: 29,
    priceYearly: 290,
    stripePriceIdMonthly: "price_pro_monthly",
    stripePriceIdYearly: "price_pro_yearly",
    features: [
      "Unlimited projects",
      "10 team members",
      "50 GB storage",
      "Priority support",
      "Advanced analytics",
    ],
    limits: { projects: -1, members: 10, storage: 50 },
    popular: true,
  },
  {
    id: "enterprise",
    name: "Enterprise",
    description: "For large organizations",
    priceMonthly: 99,
    priceYearly: 990,
    stripePriceIdMonthly: "price_enterprise_monthly",
    stripePriceIdYearly: "price_enterprise_yearly",
    features: [
      "Unlimited everything",
      "Unlimited team members",
      "500 GB storage",
      "24/7 support",
      "Custom integrations",
      "SLA guarantee",
    ],
    limits: { projects: -1, members: -1, storage: 500 },
  },
];

export function getPlan(planId: string) {
  return PLANS.find((p) => p.id === planId);
}

Step 3: Create Checkout Session

// 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(request: NextRequest) {
  const session = await auth();
  if (!session?.user) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { priceId, planId } = await request.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}/dashboard/billing?success=true`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
    customer_email: session.user.email!,
    metadata: {
      userId: session.user.id,
      planId,
    },
    subscription_data: {
      metadata: {
        userId: session.user.id,
        planId,
      },
      trial_period_days: 14,
    },
    allow_promotion_codes: true,
  });

  return NextResponse.json({ url: checkoutSession.url });
}

Step 4: Customer Portal

// app/api/stripe/portal/route.ts
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { auth } from "@/lib/auth";
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";

export async function POST() {
  const session = await auth();
  if (!session?.user) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const user = await db
    .select({ stripeCustomerId: users.stripeCustomerId })
    .from(users)
    .where(eq(users.id, session.user.id))
    .limit(1);

  if (!user[0]?.stripeCustomerId) {
    return NextResponse.json({ error: "No subscription" }, { status: 400 });
  }

  const portalSession = await stripe.billingPortal.sessions.create({
    customer: user[0].stripeCustomerId,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing`,
  });

  return NextResponse.json({ url: portalSession.url });
}

Step 5: Webhook Handler

// app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
import type Stripe from "stripe";

export async function POST(request: NextRequest) {
  const body = await request.text();
  const signature = request.headers.get("stripe-signature")!;

  let event: Stripe.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 Stripe.Checkout.Session;
      const userId = session.metadata?.userId;
      if (!userId) break;

      await db
        .update(users)
        .set({
          stripeCustomerId: session.customer as string,
          stripeSubscriptionId: session.subscription as string,
          plan: session.metadata?.planId ?? "starter",
          subscriptionStatus: "active",
        })
        .where(eq(users.id, userId));
      break;
    }

    case "customer.subscription.updated": {
      const subscription = event.data.object as Stripe.Subscription;
      const userId = subscription.metadata?.userId;
      if (!userId) break;

      await db
        .update(users)
        .set({
          subscriptionStatus: subscription.status,
          currentPeriodEnd: new Date(subscription.current_period_end * 1000).toISOString(),
        })
        .where(eq(users.id, userId));
      break;
    }

    case "customer.subscription.deleted": {
      const subscription = event.data.object as Stripe.Subscription;
      const userId = subscription.metadata?.userId;
      if (!userId) break;

      await db
        .update(users)
        .set({
          plan: "free",
          subscriptionStatus: "canceled",
          stripeSubscriptionId: null,
        })
        .where(eq(users.id, userId));
      break;
    }

    case "invoice.payment_failed": {
      const invoice = event.data.object as Stripe.Invoice;
      const customerId = invoice.customer as string;
      // Notify user about failed payment
      console.log(`Payment failed for customer: ${customerId}`);
      break;
    }
  }

  return NextResponse.json({ received: true });
}

Step 6: Billing Dashboard Component

"use client";

import { useState } from "react";
import { PLANS, type Plan } from "@/lib/plans";

interface BillingDashboardProps {
  currentPlan: string;
  subscriptionStatus: string;
  currentPeriodEnd: string | null;
}

export function BillingDashboard({
  currentPlan,
  subscriptionStatus,
  currentPeriodEnd,
}: BillingDashboardProps) {
  const [isLoading, setIsLoading] = useState(false);

  async function openPortal() {
    setIsLoading(true);
    const res = await fetch("/api/stripe/portal", { method: "POST" });
    const { url } = await res.json();
    window.location.href = url;
  }

  const plan = PLANS.find((p) => p.id === currentPlan);

  return (
    <div className="space-y-6">
      <div className="rounded-xl border p-6 dark:border-gray-700">
        <div className="flex items-center justify-between">
          <div>
            <h3 className="text-lg font-semibold">{plan?.name ?? "Free"} Plan</h3>
            <p className="text-sm text-gray-500">{plan?.description}</p>
          </div>
          <span
            className={`rounded-full px-3 py-1 text-xs font-medium ${
              subscriptionStatus === "active"
                ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
                : subscriptionStatus === "trialing"
                ? "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
                : "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
            }`}
          >
            {subscriptionStatus}
          </span>
        </div>

        {currentPeriodEnd && (
          <p className="mt-2 text-sm text-gray-400">
            {subscriptionStatus === "active" ? "Renews" : "Expires"}{" "}
            {new Date(currentPeriodEnd).toLocaleDateString()}
          </p>
        )}

        <button
          onClick={openPortal}
          disabled={isLoading}
          className="mt-4 rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"
        >
          {isLoading ? "Loading..." : "Manage Subscription"}
        </button>
      </div>

      {/* Usage */}
      {plan && (
        <div className="rounded-xl border p-6 dark:border-gray-700">
          <h3 className="mb-4 text-sm font-semibold">Usage</h3>
          <div className="space-y-3">
            <UsageBar label="Projects" used={3} limit={plan.limits.projects} />
            <UsageBar label="Team Members" used={1} limit={plan.limits.members} />
            <UsageBar label="Storage" used={1.2} limit={plan.limits.storage} unit="GB" />
          </div>
        </div>
      )}
    </div>
  );
}

function UsageBar({
  label,
  used,
  limit,
  unit = "",
}: {
  label: string;
  used: number;
  limit: number;
  unit?: string;
}) {
  const isUnlimited = limit === -1;
  const percentage = isUnlimited ? 0 : (used / limit) * 100;
  const isNearLimit = percentage > 80;

  return (
    <div>
      <div className="mb-1 flex items-center justify-between text-sm">
        <span>{label}</span>
        <span className="text-gray-500">
          {used}
          {unit} / {isUnlimited ? "Unlimited" : `${limit}${unit}`}
        </span>
      </div>
      {!isUnlimited && (
        <div className="h-2 rounded-full bg-gray-100 dark:bg-gray-800">
          <div
            className={`h-2 rounded-full transition-all ${
              isNearLimit ? "bg-orange-500" : "bg-blue-500"
            }`}
            style={{ width: `${Math.min(percentage, 100)}%` }}
          />
        </div>
      )}
    </div>
  );
}

Summary

  • Checkout sessions with trial periods and promo codes
  • Stripe Customer Portal for self-service management
  • Webhook handler for all subscription lifecycle events
  • Billing dashboard with plan info and usage tracking

Need Subscription Billing?

We build complete billing systems with Stripe for SaaS applications. Contact us to get started.

StripesubscriptionsbillingpaymentsNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles