<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged stripe-subscriptions</title>
<link>https://coderlegion.com/tag/stripe-subscriptions</link>
<description>Powered by Question2Answer</description>
<item>
<title>Stripe Subscriptions in Next.js 16: Complete SaaS Billing Guide (2026)</title>
<link>https://coderlegion.com/23381/stripe-subscriptions-in-next-js-16-complete-saas-billing-guide-2026</link>
<description>&lt;p&gt;Most developers can wire up a one-time payment in an afternoon. Subscriptions? That's where things get confusing fast.&lt;/p&gt;
&lt;p&gt;What happens when a user's card expires? What if they cancel mid-cycle? How do you know their subscription is still active before letting them into your dashboard? These aren't edge cases — they're everyday realities of any SaaS product.&lt;/p&gt;
&lt;p&gt;The good news is Stripe handles almost all of this complexity for you. Your job is knowing how to plug it in correctly. In this guide, you'll build a complete monthly subscription billing flow in Next.js 16 using Stripe Checkout Sessions — the fastest and most reliable way to add subscriptions to your SaaS.&lt;/p&gt;
&lt;p&gt;By the end, you'll have:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A pricing page that redirects users to Stripe Checkout&lt;/li&gt;
&lt;li&gt;Webhook handling that activates subscriptions in your database&lt;/li&gt;
&lt;li&gt;A customer portal where users can manage or cancel their plan&lt;/li&gt;
&lt;li&gt;A solid understanding of how subscription lifecycle events work&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-subscriptions-are-different-from-one-time-payments&quot;&gt;Why Subscriptions Are Different From One-Time Payments&lt;/h3&gt;
&lt;p&gt;A one-time payment is simple: user pays → you deliver → done.&lt;/p&gt;
&lt;p&gt;A subscription is an ongoing relationship. Stripe needs to charge the user every month, handle failed payments, retry logic, and tell your app what's happening at every step. Your backend needs to &lt;strong&gt;listen&lt;/strong&gt; to those updates via webhooks and keep your database in sync.&lt;/p&gt;
&lt;p&gt;If you skip webhooks and just trust the frontend redirect, you're asking for trouble. The user's payment could fail silently, or they could cancel their plan and still access your app for months.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-we-re-building&quot;&gt;What We're Building&lt;/h3&gt;
&lt;p&gt;A minimal SaaS billing setup with two plans:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Plan &lt;/th&gt;&lt;th&gt;Price&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Starter &lt;/td&gt;&lt;td&gt;$29/month&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Pro &lt;/td&gt;&lt;td&gt;$79/month&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The flow works like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;User clicks &quot;Subscribe&quot;
        ↓
Server Action creates Checkout Session
        ↓
User redirected to Stripe hosted checkout
        ↓
Payment completed → user redirected back
        ↓
Stripe fires webhook → your app activates subscription
        ↓
User can manage billing via Customer Portal
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;prerequisites&quot;&gt;Prerequisites&lt;/h3&gt;
&lt;p&gt;Make sure you have:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Node.js 20+ installed&lt;/li&gt;
&lt;li&gt;A Stripe account (free to create)&lt;/li&gt;
&lt;li&gt;Basic familiarity with Next.js App Router&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We'll be using:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Next.js 16&lt;/strong&gt; with App Router&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TypeScript&lt;/strong&gt; strict mode&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tailwind CSS&lt;/strong&gt; for any UI&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;React Compiler&lt;/strong&gt; (no manual &lt;code&gt;useMemo&lt;/code&gt;/&lt;code&gt;useCallback&lt;/code&gt; needed)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-1-create-a-next-js-project&quot;&gt;Step 1: Create a Next.js Project&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx create-next-app@latest stripe-subscriptions
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Choose:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;TypeScript ✅&lt;/li&gt;
&lt;li&gt;Tailwind CSS ✅&lt;/li&gt;
&lt;li&gt;App Router ✅&lt;/li&gt;
&lt;li&gt;React Compiler ✅&lt;/li&gt;
&lt;li&gt;&lt;code&gt;src/&lt;/code&gt; directory ✅&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Move into the project:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd stripe-subscriptions
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-2-install-stripe&quot;&gt;Step 2: Install Stripe&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install stripe
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the official Stripe Node.js SDK. We'll use it on the server only — it should never touch the browser.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-3-set-up-stripe-server-client&quot;&gt;Step 3: Set Up Stripe Server Client&lt;/h3&gt;
&lt;p&gt;Create a reusable Stripe instance so you don't initialize it in every file.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/lib/stripe.ts
import Stripe from &quot;stripe&quot;;

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: &quot;2026-05-27.dahlia&quot;,
});
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Never import this file into a Client Component. The &lt;code&gt;STRIPE_SECRET_KEY&lt;/code&gt; must stay server-side only.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-4-create-products-and-prices-in-stripe&quot;&gt;Step 4: Create Products and Prices in Stripe&lt;/h3&gt;
&lt;p&gt;Before writing code, set up your products in the Stripe dashboard.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Go to &lt;a rel=&quot;nofollow&quot; href=&quot;https://dashboard.stripe.com&quot;&gt;dashboard.stripe.com&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Enable &lt;strong&gt;Test Mode&lt;/strong&gt; (toggle in the top right)&lt;/li&gt;
&lt;li&gt;Copy Publishable &amp;amp; Secret keys&lt;/li&gt;
&lt;li&gt;Navigate to &lt;strong&gt;Product Catalog → Create Product&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Create two products:&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Starter Plan&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Name: &lt;code&gt;Starter&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Price: &lt;code&gt;$29.00 USD&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Billing: &lt;code&gt;Recurring → Monthly&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Pro Plan&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Name: &lt;code&gt;Pro&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Price: &lt;code&gt;$79.00 USD&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Billing: &lt;code&gt;Recurring → Monthly&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;After saving, click into each product and &lt;strong&gt;copy the Price ID&lt;/strong&gt; (starts with &lt;code&gt;price_&lt;/code&gt;). You'll need these next.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-5-configure-environment-variables&quot;&gt;Step 5: Configure Environment Variables&lt;/h3&gt;
&lt;p&gt;Create &lt;code&gt;.env.local&lt;/code&gt; in your project root:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-env&quot;&gt;# .env.local
STRIPE_SECRET_KEY=sk_test_xxxx
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxxx
NEXT_PUBLIC_APP_URL=http://localhost:3000
STRIPE_WEBHOOK_SECRET=whsec_xxxx

# Price IDs
STRIPE_STARTER_PRICE_ID=price_xxxx
STRIPE_PRO_PRICE_ID=price_xxxx
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Important:&lt;/strong&gt; Add &lt;code&gt;.env.local&lt;/code&gt; to &lt;code&gt;.gitignore&lt;/code&gt; immediately. Never commit secret keys to version control.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Variables starting with &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; are safe to expose to the browser. Everything else is server-only.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-6-define-plan-constants&quot;&gt;Step 6: Define Plan Constants&lt;/h3&gt;
&lt;p&gt;Keep your plan data in one place so you never hardcode price IDs across multiple files.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/lib/plans.ts
export const PLANS = {
  starter: {
    id: &quot;starter&quot;,
    name: &quot;Starter&quot;,
    price: 29,
    priceId: process.env.STRIPE_STARTER_PRICE_ID!,
    features: [
      &quot;Up to 5 projects&quot;,
      &quot;10GB storage&quot;,
      &quot;Email support&quot;,
    ],
  },
  pro: {
    id: &quot;pro&quot;,
    name: &quot;Pro&quot;,
    price: 79,
    priceId: process.env.STRIPE_PRO_PRICE_ID!,
    features: [
      &quot;Unlimited projects&quot;,
      &quot;100GB storage&quot;,
      &quot;Priority support&quot;,
      &quot;Advanced analytics&quot;,
    ],
  },
} as const;

export type PlanId = keyof typeof PLANS;
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Using &lt;code&gt;as const&lt;/code&gt; gives you full TypeScript autocompletion on plan IDs throughout your app — no magic strings.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-7-create-the-checkout-server-action&quot;&gt;Step 7: Create the Checkout Server Action&lt;/h3&gt;
&lt;p&gt;A &lt;strong&gt;Server Action&lt;/strong&gt; is an async function that runs on the server. We'll use one to create the Stripe Checkout Session — this keeps your secret key safe and your code clean.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/actions/create-checkout.ts
&quot;use server&quot;;

import { redirect } from &quot;next/navigation&quot;;
import { stripe } from &quot;@/lib/stripe&quot;;
import { PLANS, PlanId } from &quot;@/lib/plans&quot;;

export async function createCheckout(
  prev: unknown,
  formData: FormData
) {
  let url;
  try {
    const planId = formData.get(&quot;planId&quot;) as PlanId;

    if (!planId || !(planId in PLANS)) {
      return { error: &quot;Invalid plan selected&quot; };
    }

    const plan = PLANS[planId];

    const session = await stripe.checkout.sessions.create({
      mode: &quot;subscription&quot;,           // ← this is the key difference from one-time payments
      line_items: [
        {
          price: plan.priceId,
          quantity: 1,
        },
      ],
      success_url: `${process.env.NEXT_PUBLIC_APP_URL}/`,
      cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
      metadata: {
        planId,
      },
      // Collect billing address for invoices
      billing_address_collection: &quot;auto&quot;,
      // Allows users to apply promo codes at checkout
      allow_promotion_codes: true,
    });

    if (!session.url) {
      return { error: &quot;Failed to create checkout session&quot; };
    }
    url = session.url;
  } catch (err) {
    return {
      error: err instanceof Error ? err.message : &quot;Something went wrong&quot;,
    };
  }
  if (url) {
    redirect(url);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;mode: &quot;subscription&quot;&lt;/code&gt; tells Stripe this is a recurring payment, not a one-time charge&lt;/li&gt;
&lt;li&gt;&lt;code&gt;line_items&lt;/code&gt; specifies which price the user is subscribing to&lt;/li&gt;
&lt;li&gt;&lt;code&gt;success_url&lt;/code&gt; / &lt;code&gt;cancel_url&lt;/code&gt; control where Stripe sends the user after checkout&lt;/li&gt;
&lt;li&gt;&lt;code&gt;metadata.planId&lt;/code&gt; lets you identify which plan was purchased inside your webhook later&lt;/li&gt;
&lt;li&gt;In &lt;code&gt;subscription&lt;/code&gt; mode, Stripe automatically creates a Customer object — no extra config needed&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-8-build-the-pricing-page&quot;&gt;Step 8: Build the Pricing Page&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// src/app/pricing/page.tsx
&quot;use client&quot;;

import { useActionState } from &quot;react&quot;;
import { createCheckout } from &quot;@/actions/create-checkout&quot;;
import { PLANS } from &quot;@/lib/plans&quot;;

export default function PricingPage() {
  const [state, action, isPending] = useActionState(createCheckout, {
    error: &quot;&quot;,
  });

  return (
    &amp;lt;main className=&quot;min-h-screen py-20 px-4&quot;&amp;gt;
      &amp;lt;div className=&quot;max-w-4xl mx-auto text-center mb-12&quot;&amp;gt;
        &amp;lt;h1 className=&quot;text-4xl font-bold tracking-tight&quot;&amp;gt;
          Simple, transparent pricing
        &amp;lt;/h1&amp;gt;
        &amp;lt;p className=&quot;mt-4 text-muted-foreground text-lg&quot;&amp;gt;
          Start free. Upgrade when you're ready.
        &amp;lt;/p&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div className=&quot;grid md:grid-cols-2 gap-6 max-w-3xl mx-auto&quot;&amp;gt;
        {Object.values(PLANS).map((plan) =&amp;gt; (
          &amp;lt;div
            key={plan.id}
            className=&quot;rounded-2xl border p-8 flex flex-col gap-6&quot;
          &amp;gt;
            &amp;lt;div&amp;gt;
              &amp;lt;h2 className=&quot;text-xl font-semibold&quot;&amp;gt;{plan.name}&amp;lt;/h2&amp;gt;
              &amp;lt;div className=&quot;mt-2&quot;&amp;gt;
                &amp;lt;span className=&quot;text-4xl font-bold&quot;&amp;gt;${plan.price}&amp;lt;/span&amp;gt;
                &amp;lt;span className=&quot;text-muted-foreground&quot;&amp;gt; / month&amp;lt;/span&amp;gt;
              &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;

            &amp;lt;ul className=&quot;space-y-2 flex-1&quot;&amp;gt;
              {plan.features.map((feature) =&amp;gt; (
                &amp;lt;li key={feature} className=&quot;flex items-center gap-2 text-sm&quot;&amp;gt;
                  &amp;lt;span className=&quot;text-green-500&quot;&amp;gt;✓&amp;lt;/span&amp;gt;
                  {feature}
                &amp;lt;/li&amp;gt;
              ))}
            &amp;lt;/ul&amp;gt;

            &amp;lt;form action={action}&amp;gt;
              &amp;lt;input type=&quot;hidden&quot; name=&quot;planId&quot; value={plan.id} /&amp;gt;
              &amp;lt;button
                type=&quot;submit&quot;
                disabled={isPending}
                className=&quot;w-full rounded-xl bg-primary text-primary-foreground py-3 font-medium disabled:opacity-50 transition-opacity&quot;
              &amp;gt;
                {`Subscribe to ${plan.name}`}
              &amp;lt;/button&amp;gt;
            &amp;lt;/form&amp;gt;
          &amp;lt;/div&amp;gt;
        ))}
      &amp;lt;/div&amp;gt;

      {state?.error &amp;amp;&amp;amp; (
        &amp;lt;p className=&quot;text-red-500 text-center mt-6&quot;&amp;gt;{state.error}&amp;lt;/p&amp;gt;
      )}
    &amp;lt;/main&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The hidden &lt;code&gt;planId&lt;/code&gt; input is how the Server Action knows which plan the user clicked. The &lt;code&gt;name&lt;/code&gt; attribute on form inputs is what &lt;code&gt;formData.get(&quot;planId&quot;)&lt;/code&gt; reads.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-9-handle-subscription-events-with-webhooks&quot;&gt;Step 9: Handle Subscription Events with Webhooks&lt;/h3&gt;
&lt;p&gt;This is the most important part. When a user pays (or cancels, or their card fails), Stripe sends a POST request to your webhook endpoint. Your app listens, updates the database, and keeps subscription state accurate.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Never trust the frontend redirect alone.&lt;/strong&gt; A user could close the tab after payment, the redirect could fail, or someone could fake the success URL. Webhooks are your source of truth.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;create-the-webhook-route&quot;&gt;Create the Webhook Route&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/app/api/webhook/route.ts
import { NextResponse } from &quot;next/server&quot;;
import { headers } from &quot;next/headers&quot;;
import Stripe from &quot;stripe&quot;;
import { stripe } from &quot;@/lib/stripe&quot;;

const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(req: Request) {
  try {
    const payload = await req.text();
    const sig = (await headers()).get(&quot;stripe-signature&quot;)!;

    let event: Stripe.Event;

    // Verify the webhook actually came from Stripe
    try {
      event = stripe.webhooks.constructEvent(payload, sig, endpointSecret);
    } catch (err) {
      const message = err instanceof Error ? err.message : &quot;Unknown error&quot;;
      return NextResponse.json(
        { error: `Webhook signature failed: ${message}` },
        { status: 400 }
      );
    }

    switch (event.type) {
      // Fired when a new subscription is created and first payment succeeds
      case &quot;checkout.session.completed&quot;: {
        const session = event.data.object as Stripe.Checkout.Session;

        if (session.mode !== &quot;subscription&quot;) break;

        const customerId = session.customer as string;
        const subscriptionId = session.subscription as string;
        const planId = session.metadata?.planId;

        console.log(&quot;New subscription:&quot;, {
          customerId,
          subscriptionId,
          planId,
        });

        // TODO: Save to your database
        // await db.user.update({ stripeCustomerId: customerId, subscriptionId, plan: planId, status: &quot;active&quot; })

        break;
      }

      // Fired on every successful recurring charge
      case &quot;invoice.paid&quot;: {
        const invoice = event.data.object as Stripe.Invoice;
        const subscriptionId = invoice.subscription as string;

        console.log(&quot;Invoice paid — subscription renewed:&quot;, subscriptionId);

        // TODO: Mark subscription as active / extend access period
        // await db.subscription.update({ id: subscriptionId, status: &quot;active&quot; })

        break;
      }

      // Fired when a payment fails (expired card, insufficient funds, etc.)
      case &quot;invoice.payment_failed&quot;: {
        const invoice = event.data.object as Stripe.Invoice;
        const subscriptionId = invoice.subscription as string;

        console.log(&quot;Payment failed for subscription:&quot;, subscriptionId);

        // TODO: Notify user and mark subscription as past_due
        // await db.subscription.update({ id: subscriptionId, status: &quot;past_due&quot; })
        // await sendPaymentFailedEmail(invoice.customer_email)

        break;
      }

      // Fired when user cancels or subscription expires
      case &quot;customer.subscription.deleted&quot;: {
        const subscription = event.data.object as Stripe.Subscription;

        console.log(&quot;Subscription cancelled:&quot;, subscription.id);

        // TODO: Revoke access
        // await db.subscription.update({ id: subscription.id, status: &quot;cancelled&quot; })

        break;
      }

      // Fired when plan changes (upgrade/downgrade)
      case &quot;customer.subscription.updated&quot;: {
        const subscription = event.data.object as Stripe.Subscription;

        console.log(&quot;Subscription updated:&quot;, subscription.id, subscription.status);

        // TODO: Sync the new plan and status to your database

        break;
      }

      default:
        // Ignore unhandled event types
        break;
    }

    // Always return 200 so Stripe knows the event was received
    return NextResponse.json({ received: true }, { status: 200 });

  } catch (err) {
    const error = err instanceof Error ? err.message : &quot;Unexpected error&quot;;
    console.error(&quot;Webhook error:&quot;, error);
    return NextResponse.json({ error }, { status: 500 });
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;req.text()&lt;/code&gt; instead of &lt;code&gt;req.json()&lt;/code&gt;?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Stripe's signature verification requires the &lt;strong&gt;raw&lt;/strong&gt; request body as a string. If you parse it with &lt;code&gt;.json()&lt;/code&gt; first, the signature check will fail. Always use &lt;code&gt;.text()&lt;/code&gt; in webhook handlers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why return 200 even for events you don't handle?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Stripe retries events that don't get a 2xx response. If you return a 4xx for an unknown event type, Stripe will keep retrying it forever. Return 200 for everything that isn't a genuine error.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-10-test-webhooks-locally-with-stripe-cli&quot;&gt;Step 10: Test Webhooks Locally with Stripe CLI&lt;/h3&gt;
&lt;p&gt;Stripe can't reach &lt;code&gt;localhost&lt;/code&gt;, so you need the Stripe CLI to forward events to your dev server.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Install the Stripe CLI:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.stripe.com/stripe-cli&quot;&gt;docs.stripe.com/stripe-cli&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Then run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;stripe listen --forward-to localhost:3000/api/webhook
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This prints a webhook signing secret — copy it into your &lt;code&gt;.env.local&lt;/code&gt; as &lt;code&gt;STRIPE_WEBHOOK_SECRET&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;In a separate terminal, start your dev server:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm run dev
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now when you complete a test checkout, you'll see the events streaming in your CLI terminal.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-11-add-a-customer-portal&quot;&gt;Step 11: Add a Customer Portal&lt;/h3&gt;
&lt;p&gt;Once users are subscribed, they'll need a way to update their card, change plans, or cancel. Stripe's Customer Portal handles all of this for you.&lt;/p&gt;
&lt;h4 id=&quot;enable-the-portal-in-stripe-dashboard&quot;&gt;Enable the Portal in Stripe Dashboard&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;Settings → Billing → Customer Portal&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Enable it and configure:&lt;ul&gt;
&lt;li&gt;Allow plan changes ✅&lt;/li&gt;
&lt;li&gt;Allow cancellations ✅&lt;/li&gt;
&lt;li&gt;Allowed plans: select your Starter and Pro prices&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Save settings&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;create-the-portal-server-action&quot;&gt;Create the Portal Server Action&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/actions/create-portal.ts
&quot;use server&quot;;

import { redirect } from &quot;next/navigation&quot;;
import { stripe } from &quot;@/lib/stripe&quot;;

export async function createPortalSession(customerId: string) {
  if (!customerId) {
    throw new Error(&quot;Customer ID is required&quot;);
  }

  const session = await stripe.billingPortal.sessions.create({
    customer: customerId,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/`,
  });

  redirect(session.url);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;add-a-manage-billing-button-to-your-home&quot;&gt;Add a &quot;Manage Billing&quot; Button to Your Home&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// src/app/page.tsx
import { createPortalSession } from &quot;@/actions/create-portal&quot;;

// In a real app, fetch the stripeCustomerId from your database
const DEMO_CUSTOMER_ID = &quot;cus_xxxx&quot;; // replace with real DB lookup

export default function Page() {
    const action = createPortalSession.bind(null, DEMO_CUSTOMER_ID)
    return (
        &amp;lt;main className=&quot;p-8&quot;&amp;gt;
            &amp;lt;h1 className=&quot;text-2xl font-bold mb-6&quot;&amp;gt;Dashboard&amp;lt;/h1&amp;gt;

            &amp;lt;form
                action={action}
            &amp;gt;
                &amp;lt;button
                    type=&quot;submit&quot;
                    className=&quot;w-fit rounded-xl border p-2 text-sm font-medium hover:bg-muted transition-colors&quot;
                &amp;gt;
                    Manage Billing
                &amp;lt;/button&amp;gt;
            &amp;lt;/form&amp;gt;
        &amp;lt;/main&amp;gt;
    );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When the user clicks &quot;Manage Billing&quot;, they're taken to Stripe's hosted portal. They can update payment methods, switch plans, or cancel — and Stripe fires webhooks for every action so your database stays in sync automatically.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-12-deploy-and-configure-production-webhooks&quot;&gt;Step 12: Deploy and Configure Production Webhooks&lt;/h3&gt;
&lt;p&gt;Before going live, update your environment variables in Vercel (or your hosting provider):&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Add all your &lt;code&gt;.env.local&lt;/code&gt; variables to your Vercel project settings&lt;/li&gt;
&lt;li&gt;Switch to &lt;strong&gt;live Stripe keys&lt;/strong&gt; (&lt;code&gt;sk_live_&lt;/code&gt;, &lt;code&gt;pk_live_&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Create a new webhook in the Stripe Dashboard:&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Endpoint URL:&lt;/strong&gt; &lt;code&gt;https://yourdomain.com/api/webhook&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Events to listen for:&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;&lt;code&gt;checkout.session.completed&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;invoice.paid&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;invoice.payment_failed&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;customer.subscription.deleted&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;customer.subscription.updated&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Copy the signing secret and add it as &lt;code&gt;STRIPE_WEBHOOK_SECRET&lt;/code&gt; in Vercel&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;preventing-duplicate-processing&quot;&gt;Preventing Duplicate Processing&lt;/h3&gt;
&lt;p&gt;Stripe may send the same webhook event more than once if your server is slow or returns an error. Before processing any event, check if you've already handled it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// Inside your webhook handler, before processing:
const eventId = event.id;

// Check if already processed
const existing = await db.webhookEvent.findUnique({ where: { stripeEventId: eventId } });
if (existing) {
  return NextResponse.json({ received: true }, { status: 200 });
}

// Store the event ID first, then process
await db.webhookEvent.create({ data: { stripeEventId: eventId } });
// ... rest of processing
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is called &lt;strong&gt;idempotency&lt;/strong&gt; — handling the same event twice has the same result as handling it once.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;frequently-asked-questions&quot;&gt;Frequently Asked Questions&lt;/h3&gt;
&lt;h4 id=&quot;how-do-i-know-if-a-user-s-subscription-is-active-&quot;&gt;How do I know if a user's subscription is active?&lt;/h4&gt;
&lt;p&gt;Store the subscription status in your database when the webhook fires. On every protected page load, query your database for the user's subscription status. Don't call the Stripe API on every request — that's slow and wastes API calls. Keep your DB as the source of truth, synced by webhooks.&lt;/p&gt;
&lt;h4 id=&quot;what-s-the-difference-between-checkout-session-completed-and-invoice-paid-&quot;&gt;What's the difference between &lt;code&gt;checkout.session.completed&lt;/code&gt; and &lt;code&gt;invoice.paid&lt;/code&gt;?&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;checkout.session.completed&lt;/code&gt; fires once when a user completes the initial checkout. &lt;code&gt;invoice.paid&lt;/code&gt; fires every time a subscription renews (monthly/yearly). You need to listen to both — one to activate the account, one to keep it active.&lt;/p&gt;
&lt;h4 id=&quot;can-i-offer-a-free-trial-&quot;&gt;Can I offer a free trial?&lt;/h4&gt;
&lt;p&gt;Yes. Add &lt;code&gt;subscription_data: { trial_period_days: 14 }&lt;/code&gt; to your &lt;code&gt;stripe.checkout.sessions.create()&lt;/code&gt; call. Stripe won't charge the user until the trial ends, and it'll fire the appropriate webhook events when it does.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Set &lt;code&gt;payment_method_collection: &quot;always&quot;&lt;/code&gt; alongside a trial so Stripe collects card details upfront even though no charge happens immediately.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;how-do-i-handle-plan-upgrades-and-downgrades-&quot;&gt;How do I handle plan upgrades and downgrades?&lt;/h4&gt;
&lt;p&gt;The Customer Portal handles this automatically. When a user switches plans through the portal, Stripe fires a &lt;code&gt;customer.subscription.updated&lt;/code&gt; event with the new price ID. Listen for that event in your webhook and update the user's plan in your database.&lt;/p&gt;
&lt;h4 id=&quot;can-i-use-this-with-an-auth-system-like-clerk-or-nextauth-&quot;&gt;Can I use this with an auth system like Clerk or NextAuth?&lt;/h4&gt;
&lt;p&gt;Absolutely. After authentication, store the &lt;code&gt;stripeCustomerId&lt;/code&gt; alongside the user record in your database. When creating a checkout session for a logged-in user, pass &lt;code&gt;customer: existingStripeCustomerId&lt;/code&gt; instead of &lt;code&gt;customer_creation: &quot;always&quot;&lt;/code&gt; — this links the subscription to their existing Stripe customer.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;You now have a production-ready subscription billing flow in Next.js 16 — pricing page, Stripe Checkout, webhook handling for the full subscription lifecycle, and a customer portal for self-service billing management.&lt;/p&gt;
&lt;p&gt;The pattern here scales cleanly: add more plans to &lt;code&gt;PLANS&lt;/code&gt;, add more webhook event handlers as your app grows, and you'll never have to touch the core flow.&lt;/p&gt;
&lt;p&gt;From here, the natural next steps are connecting a real database to persist subscription status, and gating your app's features based on the user's active plan.&lt;/p&gt;
&lt;p&gt;📦 &lt;strong&gt;Source Code:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Muhammad-Ali-sma/Stripe-Subscription-Billing-Nextjs-16&quot;&gt;View on GitHub&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;useful-resources&quot;&gt;Useful Resources&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.stripe.com/billing/subscriptions/overview&quot;&gt;Stripe Subscriptions Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.stripe.com/payments/checkout&quot;&gt;Stripe Checkout Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.stripe.com/customer-management&quot;&gt;Stripe Customer Portal Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.stripe.com/api/events/types&quot;&gt;Stripe Webhook Events Reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://nextjs.org/docs/app/getting-started/mutating-data&quot;&gt;Next.js Server Actions Docs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;continue-learning&quot;&gt;Continue Learning&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/stripe-nextjs-16-guide&quot;&gt;How to Integrate Stripe Checkout with Next.js 16 (2026 Edition)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/stripe-payment-element-nextjs-16-guide&quot;&gt;How to Integrate Stripe Payment Element with Next.js 16 (2026 Edition)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/stripe-checkout-vs-payment-element&quot;&gt;Stripe Checkout vs Payment Element in 2026: Which One Should You Choose?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23381/stripe-subscriptions-in-next-js-16-complete-saas-billing-guide-2026</guid>
<pubDate>Fri, 24 Jul 2026 05:05:53 +0000</pubDate>
</item>
</channel>
</rss>