Skip to main content
Back to Blog
How Tos & TutorialsJanuary 15, 2026

Stripe Subscriptions in Next.js 15: 2026 Guide

Step-by-step tutorial for implementing Stripe subscription billing in Next.js 15. Covers checkout sessions, webhooks, customer portal, and production best practices.

LaunchKit Team

Building tools for makers

Stripe subscription billing setup guide for Next.js 15

Why Stripe Subscriptions Still Dominate in 2026

Stripe processes over $1 trillion annually. For SaaS founders, it's the default choice—but integrating subscription billing in Next.js 15 isn't as straightforward as the docs suggest.

This guide covers everything: from initial setup to production webhooks, customer portals, and the gotchas that will cost you hours if you don't know them upfront.

What You'll Build

  • Stripe Checkout for subscription signups
  • Webhook handler for payment events
  • Customer portal for self-service management
  • Access control based on subscription status
  • Proper error handling and edge cases

Prerequisites

  • Next.js 15 project with App Router
  • Stripe account (test mode for development)
  • Database for storing customer data (we'll use Supabase)
  • Basic TypeScript knowledge

Step 1: Install Dependencies

npm install stripe @stripe/stripe-js

You need both packages: stripe for server-side operations and @stripe/stripe-js for client-side redirects.

Step 2: Environment Variables

# .env.local
STRIPE_SECRET_KEY=sk_test_xxx
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx

Critical: Never expose your secret key. The NEXT_PUBLIC_ prefix means the publishable key is safe for client-side code.

Step 3: Create the Stripe Client

// libs/stripe.ts
import Stripe from "stripe";

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

Step 4: Create Checkout Session API Route

This is the core of subscription billing—creating a Checkout Session that redirects users to Stripe's hosted payment page.

// app/api/stripe/create-checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/libs/stripe";
import { createClient } from "@/libs/supabase/server";

export async function POST(req: NextRequest) {
  try {
    const supabase = await createClient();
    const { data: { user } } = await supabase.auth.getUser();

    if (!user) {
      return NextResponse.json(
        { error: "Not authenticated" },
        { status: 401 }
      );
    }

    const { priceId, successUrl, cancelUrl } = await req.json();

    // Check if customer already exists
    const { data: profile } = await supabase
      .from("profiles")
      .select("stripe_customer_id")
      .eq("id", user.id)
      .single();

    let customerId = profile?.stripe_customer_id;

    // Create Stripe customer if needed
    if (!customerId) {
      const customer = await stripe.customers.create({
        email: user.email,
        metadata: { supabase_user_id: user.id },
      });
      customerId = customer.id;

      // Save customer ID to database
      await supabase
        .from("profiles")
        .update({ stripe_customer_id: customerId })
        .eq("id", user.id);
    }

    // Create checkout session
    const session = await stripe.checkout.sessions.create({
      customer: customerId,
      mode: "subscription",
      payment_method_types: ["card"],
      line_items: [{ price: priceId, quantity: 1 }],
      success_url: successUrl || `${process.env.NEXT_PUBLIC_SITE_URL}/dashboard?success=true`,
      cancel_url: cancelUrl || `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?canceled=true`,
      metadata: { user_id: user.id },
    });

    return NextResponse.json({ url: session.url });
  } catch (error) {
    console.error("Checkout error:", error);
    return NextResponse.json(
      { error: "Failed to create checkout session" },
      { status: 500 }
    );
  }
}

Step 5: The Webhook Handler (Most Important Part)

Webhooks are where most developers go wrong. Stripe sends events asynchronously, and your app must handle them reliably.

// app/api/webhook/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/libs/stripe";
import { createClient } from "@supabase/supabase-js";
import Stripe from "stripe";

// Use service role for webhook (no user context)
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);

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

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    console.error("Webhook signature verification failed");
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }

  // Handle subscription events
  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      await handleCheckoutComplete(session);
      break;
    }
    case "customer.subscription.updated":
    case "customer.subscription.deleted": {
      const subscription = event.data.object as Stripe.Subscription;
      await handleSubscriptionChange(subscription);
      break;
    }
    case "invoice.payment_failed": {
      const invoice = event.data.object as Stripe.Invoice;
      await handlePaymentFailed(invoice);
      break;
    }
  }

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

async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
  const customerId = session.customer as string;
  const subscriptionId = session.subscription as string;

  // Get subscription details
  const subscription = await stripe.subscriptions.retrieve(subscriptionId);
  const priceId = subscription.items.data[0].price.id;

  // Update user access
  await supabase
    .from("profiles")
    .update({
      has_access: true,
      price_id: priceId,
      stripe_subscription_id: subscriptionId,
    })
    .eq("stripe_customer_id", customerId);
}

async function handleSubscriptionChange(subscription: Stripe.Subscription) {
  const customerId = subscription.customer as string;
  const hasAccess = subscription.status === "active" ||
                    subscription.status === "trialing";

  await supabase
    .from("profiles")
    .update({
      has_access: hasAccess,
      price_id: hasAccess ? subscription.items.data[0].price.id : null,
    })
    .eq("stripe_customer_id", customerId);
}

async function handlePaymentFailed(invoice: Stripe.Invoice) {
  // Log for follow-up, but don't revoke access immediately
  // Stripe will retry and send subscription.updated if it fails completely
  console.log("Payment failed for customer:", invoice.customer);
}

Step 6: Customer Portal for Self-Service

Let users manage their own subscriptions—update payment methods, change plans, or cancel.

// app/api/stripe/create-portal/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/libs/stripe";
import { createClient } from "@/libs/supabase/server";

export async function POST(req: NextRequest) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) {
    return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
  }

  const { data: profile } = await supabase
    .from("profiles")
    .select("stripe_customer_id")
    .eq("id", user.id)
    .single();

  if (!profile?.stripe_customer_id) {
    return NextResponse.json({ error: "No subscription found" }, { status: 400 });
  }

  const session = await stripe.billingPortal.sessions.create({
    customer: profile.stripe_customer_id,
    return_url: `${process.env.NEXT_PUBLIC_SITE_URL}/dashboard`,
  });

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

Step 7: Protect Routes Based on Subscription

// middleware.ts or in your page component
import { createClient } from "@/libs/supabase/server";
import { redirect } from "next/navigation";

export default async function ProtectedPage() {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) {
    redirect("/login");
  }

  const { data: profile } = await supabase
    .from("profiles")
    .select("has_access")
    .eq("id", user.id)
    .single();

  if (!profile?.has_access) {
    redirect("/pricing");
  }

  // User has active subscription - render protected content
  return <div>Premium content here</div>;
}

Common Gotchas and How to Avoid Them

1. Webhook Signature Verification Fails

You must use req.text() not req.json() for the raw body. Stripe needs the exact bytes to verify the signature.

2. Duplicate Webhook Events

Stripe may send the same event multiple times. Make your handlers idempotent—check if the subscription status already matches before updating.

3. Testing Webhooks Locally

# Install Stripe CLI
stripe listen --forward-to localhost:3000/api/webhook/stripe

# In another terminal, trigger test events
stripe trigger checkout.session.completed

4. Race Conditions

The checkout success redirect can arrive before the webhook. Show a "setting up your account" message and poll for access status.

Production Checklist

  • ✅ Switch to live Stripe keys
  • ✅ Configure webhook endpoint in Stripe Dashboard
  • ✅ Set up Customer Portal branding
  • ✅ Configure invoice settings (automatic vs manual)
  • ✅ Set up failed payment email notifications
  • ✅ Test the full flow end-to-end

Skip the Setup: Use LaunchKit

This guide covers ~500 lines of code and several hours of setup. LaunchKit includes all of this pre-configured, tested, and ready to use in minutes.

Instead of debugging webhook signatures at 2 AM, focus on what makes your product unique.

Ready to ship faster?

LaunchKit gives you auth, payments, CRM, and everything you need to launch your SaaS in days, not months.

Get LaunchKit

Written by

LaunchKit Team

We're a small team passionate about helping developers and entrepreneurs ship products faster. LaunchKit is our contribution to the maker community.

Related Articles