All Guides
Guide

How to Add Subscriptions to a Next.js App

Next.js is the most popular framework for SaaS. Learn how to add authentication, subscription billing, and feature gating with StackBE.

Published January 12, 2026Updated January 25, 2026

Next.js + StackBE: The SaaS Stack

Next.js is the most popular React framework for building SaaS applications. Its server-side rendering, API routes, and middleware make it ideal for subscription-based products.

StackBE provides the billing backend: authentication, subscriptions, and entitlements. Together, they form a complete SaaS stack.

Architecture

Next.js App (frontend + API routes)

↕ SDK / API calls

StackBE (auth, billing, entitlements)

↕ Payment processing

Stripe (payment gateway via Connect)

What Lives Where

Next.js handles:

  • UI rendering (React components)
  • API routes for your product logic
  • Middleware for auth protection
  • Server-side entitlement checks
  • StackBE handles:

  • Customer authentication (magic links)
  • Subscription management
  • Entitlements (what each plan includes)
  • Stripe integration
  • Webhooks from Stripe
  • Getting Started

    1. Install the SDK

    npm install @stackbe/sdk

    2. Configure Environment

    # .env.local

    STACKBE_API_KEY=sk_live_your_key

    STACKBE_APP_ID=app_your_id

    NEXT_PUBLIC_APP_URL=http://localhost:3000

    3. Initialize the SDK

    Create a shared StackBE client:

    // lib/stackbe.ts

    import { StackBE } from '@stackbe/sdk';

    export const stackbe = new StackBE({

    apiKey: process.env.STACKBE_API_KEY!,

    appId: process.env.STACKBE_APP_ID!,

    });

    Authentication

    Magic Link Login Page

    // app/login/page.tsx

    'use client';

    import { useState } from 'react';

    export default function LoginPage() {

    const [email, setEmail] = useState('');
    const [sent, setSent] = useState(false);
    const handleSubmit = async (e: React.FormEvent) => {

    e.preventDefault();

    await fetch('/api/auth/magic-link', {

    method: 'POST',

    headers: { 'Content-Type': 'application/json' },

    body: JSON.stringify({ email }),

    });

    setSent(true);

    };
    if (sent) {
    return (

    <div>

    <h1>Check your email</h1>

    <p>We sent a login link to {email}</p>

    </div>

    );

    }
    return (

    <form onSubmit={handleSubmit}>

    <input

    type="email"

    value={email}

    onChange={(e) => setEmail(e.target.value)}

    placeholder="you@example.com"

    required

    />

    <button type="submit">Send Magic Link</button>

    </form>

    );

    }

    Magic Link API Route

    // app/api/auth/magic-link/route.ts

    import { stackbe } from '@/lib/stackbe';

    import { NextResponse } from 'next/server';

    export async function POST(request: Request) {

    const { email } = await request.json();
    await stackbe.auth.sendMagicLink(email, {

    callbackUrl: `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback`,

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

    Auth Callback Handler

    // app/auth/callback/route.ts

    import { NextResponse } from 'next/server';

    import { cookies } from 'next/headers';

    export async function GET(request: Request) {

    const { searchParams } = new URL(request.url);
    const token = searchParams.get('token');
    if (!token) {
    return NextResponse.redirect('/login?error=missing_token');
    }
    // Set session cookie

    cookies().set('session', token, {

    httpOnly: true,

    secure: process.env.NODE_ENV === 'production',

    sameSite: 'lax',

    maxAge: 60 * 60 * 24 * 30, // 30 days

    });
    return NextResponse.redirect(new URL('/dashboard', request.url));
    }

    Auth Middleware

    Protect routes with Next.js middleware:

    // middleware.ts

    import { NextResponse } from 'next/server';

    import type { NextRequest } from 'next/server';

    export function middleware(request: NextRequest) {

    const session = request.cookies.get('session');
    if (!session) {
    return NextResponse.redirect(new URL('/login', request.url));
    }
    return NextResponse.next();
    }

    export const config = {

    matcher: ['/dashboard/:path*', '/settings/:path*'],

    };

    Subscription Management

    Pricing Page with Checkout

    // app/pricing/page.tsx

    'use client';

    export default function PricingPage() {

    const handleSubscribe = async (planId: string) => {
    const response = await fetch('/api/checkout', {

    method: 'POST',

    headers: { 'Content-Type': 'application/json' },

    body: JSON.stringify({ planId }),

    });
    const { url } = await response.json();

    window.location.href = url;

    };
    return (

    <div>

    <div>

    <h3>Free</h3>

    <p>$0/month</p>

    <ul>

    <li>Basic features</li>

    <li>100 API calls/month</li>

    </ul>

    <button onClick={() => handleSubscribe('plan_free')}>

    Get Started

    </button>

    </div>

    <div>

    <h3>Pro</h3>

    <p>$29/month</p>

    <ul>

    <li>All features</li>

    <li>Unlimited API calls</li>

    <li>Priority support</li>

    </ul>

    <button onClick={() => handleSubscribe('plan_pro')}>

    Subscribe

    </button>

    </div>

    </div>

    );

    }

    Checkout API Route

    // app/api/checkout/route.ts

    import { stackbe } from '@/lib/stackbe';

    import { cookies } from 'next/headers';

    import { NextResponse } from 'next/server';

    export async function POST(request: Request) {

    const session = cookies().get('session')?.value;
    const { planId } = await request.json();
    const { url } = await stackbe.checkout.createSession({

    customer: session, // or customer ID

    planId,

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

    cancelUrl: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,

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

    Entitlements & Feature Gating

    Server Component with Entitlements

    // app/dashboard/page.tsx

    import { stackbe } from '@/lib/stackbe';

    import { cookies } from 'next/headers';

    export default async function Dashboard() {

    const session = cookies().get('session')?.value;
    // Get subscription with plan details
    const subscription = await stackbe.subscriptions.get(session!, {

    expand: ['plan'],

    });
    const { hasAccess: canExport } = await stackbe.entitlements.check(

    session!,

    'advanced_export'

    );

    return (

    <div>

    <h1>Dashboard</h1>

    <p>Plan: {subscription?.plan?.name || 'Free'}</p>

    {canExport ? (

    <ExportButton />

    ) : (

    <UpgradePrompt feature="Advanced Export" />

    )}

    </div>

    );

    }

    API Route Protection

    // app/api/export/route.ts

    import { stackbe } from '@/lib/stackbe';

    import { cookies } from 'next/headers';

    import { NextResponse } from 'next/server';

    export async function POST(request: Request) {

    const session = cookies().get('session')?.value;
    const { hasAccess } = await stackbe.entitlements.check(

    session!,

    'advanced_export'

    );

    if (!hasAccess) {
    return NextResponse.json(
    { error: 'Upgrade to Pro for this feature' },
    { status: 403 }

    );

    }
    // Track usage
    await stackbe.usage.track(session!, 'exports');
    // Perform export...
    return NextResponse.json({ downloadUrl: '...' });
    }

    Client-Side Entitlement Hook

    // hooks/useEntitlements.ts

    'use client';

    import { useEffect, useState } from 'react';

    export function useEntitlements() {

    const [entitlements, setEntitlements] = useState<string[]>([]);
    const [plan, setPlan] = useState<string>('free');
    const [loading, setLoading] = useState(true);

    useEffect(() => {

    fetch('/api/me/entitlements')

    .then(res => res.json())

    .then(data => {

    setEntitlements(data.entitlements);

    setPlan(data.plan);

    setLoading(false);

    });
    }, []);
    const hasFeature = (feature: string) => entitlements.includes(feature);
    return { entitlements, plan, loading, hasFeature };
    }

    Handling Webhooks

    Webhook Route

    // app/api/webhooks/stackbe/route.ts

    import { NextResponse } from 'next/server';

    export async function POST(request: Request) {

    const event = await request.json();

    switch (event.type) {

    case 'subscription.created':

    // New subscription - provision access

    console.log('New subscription:', event.data);

    break;

    case 'subscription.canceled':

    // Subscription canceled - handle cleanup

    console.log('Canceled:', event.data);

    break;

    case 'subscription.updated':

    // Plan change - update entitlements

    console.log('Updated:', event.data);

    break;

    case 'invoice.payment_failed':

    // Payment failed - notify customer

    console.log('Payment failed:', event.data);

    break;

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

    Common Patterns

    Subscription Status Banner

    // components/SubscriptionBanner.tsx

    'use client';

    import { useEntitlements } from '@/hooks/useEntitlements';

    export function SubscriptionBanner() {

    const { plan } = useEntitlements();
    if (plan === 'pro') return null;
    return (

    <div className="bg-yellow-900/20 border border-yellow-800 p-3 rounded">

    <p>You're on the Free plan.</p>

    <a href="/pricing" className="text-green-400 underline">

    Upgrade to Pro

    </a>

    {' '}for unlimited access.

    </div>

    );

    }

    Usage Limit Display

    // components/UsageDisplay.tsx

    export async function UsageDisplay({ customerId }: { customerId: string }) {

    const usage = await stackbe.usage.get(customerId, 'api_calls');
    return (

    <div>

    <p>{usage.current} / {usage.limit} API calls this month</p>

    <div className="w-full bg-neutral-800 rounded">

    <div

    className="bg-green-500 h-2 rounded"

    style={{ width: `${(usage.current / usage.limit) * 100}%` }}

    />

    </div>

    </div>

    );

    }

    Deployment

    Vercel (Recommended)

    Next.js deploys naturally to Vercel:

    1. Push to GitHub

    2. Connect repo to Vercel

    3. Set environment variables in Vercel dashboard

    4. Deploy

    Environment Variables

    Set in Vercel:

  • `STACKBE_API_KEY`: Your StackBE API key (server-side only)
  • `STACKBE_APP_ID`: Your StackBE app ID
  • `NEXT_PUBLIC_APP_URL`: Your production URL
  • Best Practices

    Server-Side Entitlement Checks

    Always verify entitlements server-side (API routes, server components). Client-side checks are for UI display only—they can be bypassed.

    Cache Subscription Data

    Don't fetch subscription status on every page load. Use React context or a shared fetch that caches the result per session.

    Handle Loading States

    Subscription checks are async. Show loading states while checking, not blank areas or flash of premium content.

    Test with Multiple Plans

    Create test customers on different plans. Verify feature gating works correctly for each tier.

    Next Steps

    1. Install the SDK: `npm install @stackbe/sdk`

    2. Set up authentication: Magic link flow with callback

    3. Define your plans: In StackBE dashboard

    4. Implement checkout: Pricing page with Stripe redirect

    5. Gate features: Server and client entitlement checks

    6. Deploy: Vercel with environment variables

    Ready to simplify your SaaS backend?

    StackBE combines auth, billing, and entitlements in one API. Get started in minutes, not weeks.

    Get Started Free

    Frequently Asked Questions