Skip to main content

Build a Stripe Subscription SaaS: Complete 2026 Guide

·APIScout Team
stripesubscriptionssaaspaymentsnext-js2026

TL;DR

A complete Stripe subscription integration requires about 6 components: create products/prices (once), checkout session (new subscriptions), customer portal (manage subscriptions), webhooks (sync payment state), middleware guard (protect premium features), and billing page (show status + portal link). Each part is straightforward in isolation, but they must connect correctly or subscriptions go out of sync. This guide covers all six with production-ready Next.js code.

Key Takeaways

  • Never store plan state yourself — derive it from Stripe via webhooks
  • Checkout Session → creates subscriptions, returns stripeCustomerId in webhook
  • Customer Portal → lets users upgrade, downgrade, cancel without you building a billing UI
  • Webhooks → the single source of truth for subscription state changes
  • Trial periods: set in subscription_data.trial_period_days, tracked via subscription.status === 'trialing'
  • Dunning: Stripe retries automatically, you just send notification emails on invoice.payment_failed

Step 1: Create Products and Prices (Stripe Dashboard or API)

// scripts/seed-stripe.ts — run once to set up products/prices:
import Stripe from 'stripe';

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

// Create product:
const product = await stripe.products.create({
  name: 'Pro Plan',
  description: 'Full access to all features',
});

// Create monthly price:
const monthlyPrice = await stripe.prices.create({
  product: product.id,
  unit_amount: 2900,          // $29.00 in cents
  currency: 'usd',
  recurring: { interval: 'month' },
  nickname: 'Pro Monthly',
});

// Create annual price (17% discount):
const annualPrice = await stripe.prices.create({
  product: product.id,
  unit_amount: 29000,         // $290/year vs $348/year monthly
  currency: 'usd',
  recurring: { interval: 'year' },
  nickname: 'Pro Annual',
});

console.log('Monthly Price ID:', monthlyPrice.id);  // price_xxx — save to .env
console.log('Annual Price ID:', annualPrice.id);
# .env
STRIPE_PRO_MONTHLY_PRICE_ID=price_xxxMonthly
STRIPE_PRO_ANNUAL_PRICE_ID=price_xxxAnnual

Step 2: Checkout Session (New Subscriptions)

// app/api/billing/checkout/route.ts
import Stripe from 'stripe';
import { auth } from '@/auth';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const session = await auth();
  if (!session?.user) return new Response('Unauthorized', { status: 401 });

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

  // Validate the price ID:
  const validPrices = [
    process.env.STRIPE_PRO_MONTHLY_PRICE_ID,
    process.env.STRIPE_PRO_ANNUAL_PRICE_ID,
  ];
  if (!validPrices.includes(priceId)) {
    return new Response('Invalid price', { status: 400 });
  }

  const user = await db.user.findUnique({ where: { id: session.user.id } });

  const checkoutSession = await stripe.checkout.sessions.create({
    mode: 'subscription',
    payment_method_types: ['card'],
    line_items: [{ price: priceId, quantity: 1 }],

    // Pass user info to pre-fill checkout:
    customer_email: user?.stripeCustomerId ? undefined : session.user.email,
    customer: user?.stripeCustomerId ?? undefined,

    // Metadata links the checkout to your user:
    metadata: { userId: session.user.id },
    subscription_data: {
      metadata: { userId: session.user.id },
      trial_period_days: 14,  // Optional: 14-day free trial
    },

    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?upgraded=true`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,

    // Allow users to apply promo codes:
    allow_promotion_codes: true,
  });

  return Response.json({ url: checkoutSession.url });
}
// components/UpgradeButton.tsx:
'use client';

export function UpgradeButton({ priceId }: { priceId: string }) {
  const [loading, setLoading] = useState(false);

  const handleUpgrade = async () => {
    setLoading(true);
    const res = await fetch('/api/billing/checkout', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ priceId }),
    });
    const { url } = await res.json();
    window.location.href = url;  // Redirect to Stripe-hosted checkout
  };

  return (
    <button onClick={handleUpgrade} disabled={loading}>
      {loading ? 'Redirecting...' : 'Upgrade to Pro'}
    </button>
  );
}

Step 3: Customer Portal (Self-Service Billing Management)

// app/api/billing/portal/route.ts
import Stripe from 'stripe';
import { auth } from '@/auth';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST() {
  const session = await auth();
  if (!session?.user) return new Response('Unauthorized', { status: 401 });

  const user = await db.user.findUnique({ where: { id: session.user.id } });

  if (!user?.stripeCustomerId) {
    return new Response('No subscription found', { status: 400 });
  }

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

  // Redirect to the portal:
  return Response.redirect(portalSession.url);
}

The Customer Portal handles: plan changes, cancellation, payment method updates, invoice history — all without you building any UI.


Step 4: Webhooks (Source of Truth)

// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
import { headers } from 'next/headers';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

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

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return new Response('Invalid signature', { status: 400 });
  }

  // Process async, return 200 immediately:
  handleEvent(event).catch(console.error);
  return new Response('OK', { status: 200 });
}

async function handleEvent(event: Stripe.Event) {
  // Idempotency check:
  const existing = await db.stripeEvent.findUnique({
    where: { stripeEventId: event.id },
  });
  if (existing) return;

  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session;
      if (session.mode !== 'subscription') break;

      const subscription = await stripe.subscriptions.retrieve(
        session.subscription as string
      );

      // Store the customer ID from the checkout:
      await db.user.update({
        where: { id: session.metadata?.userId },
        data: {
          stripeCustomerId: session.customer as string,
          stripeSubscriptionId: subscription.id,
          subscriptionStatus: subscription.status,
          plan: getPlanFromPriceId(subscription.items.data[0].price.id),
          currentPeriodEnd: new Date(subscription.current_period_end * 1000),
          trialEndsAt: subscription.trial_end
            ? new Date(subscription.trial_end * 1000)
            : null,
        },
      });
      break;
    }

    case 'customer.subscription.updated': {
      const subscription = event.data.object as Stripe.Subscription;
      await syncSubscription(subscription);
      break;
    }

    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription;
      await db.user.update({
        where: { stripeSubscriptionId: subscription.id },
        data: {
          plan: 'free',
          subscriptionStatus: 'canceled',
          stripeSubscriptionId: null,
          currentPeriodEnd: null,
        },
      });
      break;
    }

    case 'invoice.payment_failed': {
      const invoice = event.data.object as Stripe.Invoice;
      const user = await db.user.findUnique({
        where: { stripeCustomerId: invoice.customer as string },
      });
      if (user) {
        await sendPaymentFailedEmail(user.email);
      }
      break;
    }
  }

  await db.stripeEvent.create({
    data: { stripeEventId: event.id, type: event.type, processedAt: new Date() },
  });
}

async function syncSubscription(subscription: Stripe.Subscription) {
  await db.user.update({
    where: { stripeSubscriptionId: subscription.id },
    data: {
      plan: getPlanFromPriceId(subscription.items.data[0].price.id),
      subscriptionStatus: subscription.status,
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
      cancelAtPeriodEnd: subscription.cancel_at_period_end,
    },
  });
}

function getPlanFromPriceId(priceId: string): string {
  const map: Record<string, string> = {
    [process.env.STRIPE_PRO_MONTHLY_PRICE_ID!]: 'pro',
    [process.env.STRIPE_PRO_ANNUAL_PRICE_ID!]: 'pro',
  };
  return map[priceId] ?? 'free';
}

Step 5: Protect Premium Features

// lib/subscription.ts — check subscription status:
import { auth } from '@/auth';

export async function requirePro() {
  const session = await auth();
  if (!session?.user) redirect('/login');

  const user = await db.user.findUnique({ where: { id: session.user.id } });

  const hasAccess =
    user?.plan === 'pro' &&
    (user?.subscriptionStatus === 'active' || user?.subscriptionStatus === 'trialing');

  if (!hasAccess) redirect('/pricing?upgrade=required');
  return user;
}
// app/dashboard/advanced/page.tsx — premium-only page:
import { requirePro } from '@/lib/subscription';

export default async function AdvancedPage() {
  const user = await requirePro();  // Redirects to /pricing if not pro

  return <div>Welcome, {user.name}! Premium feature here.</div>;
}

Step 6: Billing Page

// app/dashboard/billing/page.tsx
import { auth } from '@/auth';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export default async function BillingPage() {
  const session = await auth();
  const user = await db.user.findUnique({ where: { id: session!.user.id } });

  // Fetch current invoice if subscribed:
  let nextInvoice = null;
  if (user?.stripeSubscriptionId) {
    const upcoming = await stripe.invoices.retrieveUpcoming({
      customer: user.stripeCustomerId!,
    }).catch(() => null);
    if (upcoming) {
      nextInvoice = {
        amount: upcoming.amount_due / 100,
        date: new Date(upcoming.next_payment_attempt! * 1000),
      };
    }
  }

  return (
    <div>
      <h1>Billing</h1>

      <p>Current plan: {user?.plan ?? 'Free'}</p>
      <p>Status: {user?.subscriptionStatus ?? 'N/A'}</p>

      {user?.trialEndsAt && new Date() < user.trialEndsAt && (
        <p>Trial ends: {user.trialEndsAt.toLocaleDateString()}</p>
      )}

      {user?.currentPeriodEnd && (
        <p>
          {user.cancelAtPeriodEnd ? 'Access ends' : 'Next billing date'}:{' '}
          {user.currentPeriodEnd.toLocaleDateString()}
        </p>
      )}

      {nextInvoice && (
        <p>Next invoice: ${nextInvoice.amount.toFixed(2)} on {nextInvoice.date.toLocaleDateString()}</p>
      )}

      {user?.stripeCustomerId ? (
        <form action="/api/billing/portal" method="POST">
          <button type="submit">Manage Subscription</button>
        </form>
      ) : (
        <a href="/pricing">Upgrade to Pro</a>
      )}
    </div>
  );
}

Explore all payment and billing APIs at APIScout.

Comments