AI SaaS Starter KitAI SaaS Starter KitProduction-ready SaaS boilerplate with Next.js 16, Auth.js, Stripe subscriptions, Prisma, dashboard, teams, admin CMS, and AI-ready API hooks.$29

A logged-in user is not automatically a paying customer.SaaS plan gating with Stripe subscription status in Next.jsmeans your app reads entitlements from the database (synced by webhooks) and decides whether to render Pro analytics, call AI routes, or open team settings.Auth.js middlewareanswers “who is this?” plan gating answers “what can they use?”

This guide implements server-sideStripe subscription plan gatingaftersubscription sync to Prisma: helper functions onSubscriptionrows, gates in Server Components and Route Handlers, cancel-at-period-end rules, and price-id-to-plan mapping, without calling the Stripe API on every dashboard click.


Authentication vs entitlement

Check Data source Blocks Session / middleware Auth.js cookie +Sessionrow Anonymous users →/loginPlan gate PrismaSubscriptionforuserIdFree users → upsell or 403 Usage gate (later) Credits / metered usage Over-limit AI calls

Checkout and theCustomer Portalchange Stripe;webhooksupdate Prisma. Your gates read Prisma only so pages stay fast when Stripe is slow.

Founders often ship “Pro” as a boolean set in Checkout success URL query params. That breaks the moment a user cancels in the portal, misses a webhook, or opens two browser tabs during upgrade.Stripe subscription status in Prismais the durable entitlement layer; gates should fail closed when the row is missing or inactive.

Prerequisites

  • User+Subscriptionmodels and webhook upserts (articles 01, 03).

  • Auth.js login + middleware (articles 05–06).

  • Stripeprice_...ids for Pro and Business (or equivalent tiers).


Step 1: Subscription status you should trust

Map Stripe webhook state to an enum in Prisma (example):

enum SubscriptionStatus {
  ACTIVE
  TRIALING
  PAST_DUE
  CANCELED
  UNPAID
  INCOMPLETE
}

Typical access policy:

Status Grant Pro access? Notes ACTIVE Yes Paid or cancel-at-period-end still active TRIALING Yes Time-box trials in app or via Stripe trial PAST_DUE Your call Grace period vs hard block CANCELED No* *Yes untilcurrentPeriodEndif still in paid period UNPAID / INCOMPLETE No Checkout abandoned or failed

Document grace forPAST_DUEin support macros. That is a product decision, not a framework default.


Step 2: Access helpers inlib/billing/access.ts

import { db } from "@/lib/db";
import type { Subscription, SubscriptionStatus } from "@prisma/client";

const ACCESS_STATUSES: SubscriptionStatus[] = ["ACTIVE", "TRIALING"];

export async function getUserSubscription(userId: string) {
  return db.subscription.findFirst({
    where: { userId },
    orderBy: { currentPeriodEnd: "desc" },
  });
}

export function subscriptionGrantsAccess(
  sub: Subscription | null,
  options?: { allowPastDue?: boolean },
): boolean {
  if (!sub) return false;

  const allowed = [...ACCESS_STATUSES];
  if (options?.allowPastDue) allowed.push("PAST_DUE");

  if (!allowed.includes(sub.status)) {
    return false;
  }

  if (sub.currentPeriodEnd && sub.currentPeriodEnd.getTime() < Date.now()) {
    return false;
  }

  return true;
}

export type PlanTier = "free" | "pro" | "business";

const PRICE_TO_TIER: Record<string, PlanTier> = {
  [process.env.STRIPE_PRICE_PRO!]: "pro",
  [process.env.STRIPE_PRICE_BUSINESS!]: "business",
};

export function tierFromSubscription(sub: Subscription | null): PlanTier {
  if (!sub || !subscriptionGrantsAccess(sub)) return "free";
  return PRICE_TO_TIER[sub.stripePriceId] ?? "pro";
}

export function tierAllowsFeature(tier: PlanTier, feature: "analytics" | "teams" | "ai") {
  const matrix = {
    free: [] as string[],
    pro: ["analytics", "ai"],
    business: ["analytics", "ai", "teams"],
  };
  return matrix[tier].includes(feature);
}

KeepSTRIPE_PRICE_PROandSTRIPE_PRICE_BUSINESSin env. Use the same values Checkout uses.


Step 3: Cancel at period end

When a user cancels in the portal, Stripe often setscancel_at_period_endwhile status staysactiveuntil the period ends. Your webhook handler should persistcurrentPeriodEndand eventualCANCELEDfromsubscription updated/deletedevents.

Gate logic:

export function subscriptionGrantsAccess(sub: Subscription | null): boolean {
  if (!sub) return false;

  const statusOk = ["ACTIVE", "TRIALING"].includes(sub.status);
  const notExpired =
    !sub.currentPeriodEnd || sub.currentPeriodEnd.getTime() > Date.now();

  return statusOk && notExpired;
}

When status flips toCANCELEDbut Stripe still grants access until period end, your webhook may keep statusACTIVEwith acancelAtPeriodEndboolean on the row. Either pattern works if gates match webhook semantics.One source of truth:whatever the webhook writes.

Past due grace:B2B SaaS sometimes allows seven days of Pro access while Stripe retries the card. Model that asPAST_DUEinsubscriptionGrantsAccess({ allowPastDue: true })only on non-destructive features, or show a billing banner instead of hard lock. Document the policy in your terms.

Multiple subscriptions:Rare for B2B, but agencies might have two rows.findFirstwithorderBy: { currentPeriodEnd: "desc" }picks the newest; alternatively delete old rows in webhook handlers when Stripe replaces a subscription id.


Step 4: Gate Server Components (pages and layouts)

Pro-only analytics page:

// app/dashboard/analytics/page.tsx
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import {
  getUserSubscription,
  subscriptionGrantsAccess,
  tierFromSubscription,
  tierAllowsFeature,
} from "@/lib/billing/access";

export default async function AnalyticsPage() {
  const session = await auth();
  if (!session?.user?.id) redirect("/login");

  const sub = await getUserSubscription(session.user.id);
  const tier = tierFromSubscription(sub);

  if (!tierAllowsFeature(tier, "analytics")) {
    redirect("/pricing?upgrade=analytics");
  }

  // Fetch analytics server-side. Do not run this branch for free users.
  return (
    <main>
      <h1>Analytics</h1>
      {/* Pro charts */}
    </main>
  );
}

Preferredirect or upsellover rendering empty shells that leak layout hints. Free users can still use/dashboardif middleware allows. Only Pro routes redirect.

Optional wrapper:

export async function requireProFeature(userId: string, feature: "analytics" | "teams" | "ai") {
  const sub = await getUserSubscription(userId);
  const tier = tierFromSubscription(sub);
  if (!tierAllowsFeature(tier, feature)) {
    redirect(`/pricing?upgrade=${feature}`);
  }
}

Step 5: Gate Route Handlers (API)

// app/api/analytics/summary/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { getUserSubscription, tierFromSubscription, tierAllowsFeature } from "@/lib/billing/access";

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

  const sub = await getUserSubscription(session.user.id);
  const tier = tierFromSubscription(sub);

  if (!tierAllowsFeature(tier, "analytics")) {
    return NextResponse.json({ error: "Pro plan required" }, { status: 403 });
  }

  const data = await loadAnalyticsSummary(session.user.id);
  return NextResponse.json(data);
}

Never acceptuserIdfrom the client body for billing checks. Always usesession.user.id.

/api/stripe/webhookstays unauthenticated;/api/stripe/portalrequires session (article 04).


Step 6: UI pattern (locked state without leaking data)

Show free users alocked cardon the dashboard home instead of a hard redirect:

const tier = tierFromSubscription(sub);
const hasAnalytics = tierAllowsFeature(tier, "analytics");

return (
  <section>
    {hasAnalytics ? (
      <AnalyticsPreview userId={session.user.id} />
    ) : (
      <LockedFeature
        title="Analytics"
        href="/pricing?upgrade=analytics"
        description="Upgrade to Pro to view usage and revenue insights."
      />
    )}
  </section>
);

Do not pass Pro-only metrics as props to client components whenhasAnalyticsis false. Tree-shake the data fetch behind the branch.

Server Actions:If Pro features mutate data via Server Actions, duplicate the gate at the top of each action. Clients can invoke actions directly. Same pattern as Route Handlers:auth()+tierAllowsFeature.

React Server Components streaming:Gate before awaiting expensive queries. If you stream a shell first, ensure the Pro data slot does not start fetching until entitlement passes, otherwise timing side channels can hint at data existence.


Step 7: Do not call Stripe on every request

Anti-pattern:

const sub = await stripe.subscriptions.retrieve(user.stripeSubscriptionId); // every page load

Webhook sync + Prisma read scales better and matchesidempotent webhookdesign. Optional nightly reconciliation cron can fix drift, not the hot path.

After portal changes, users may see stale UI for one request until webhook delivery;revalidatePath("/dashboard")on billing return is a UX polish, not a substitute for webhooks.


Step 8: Test checklist

  1. Free user:/dashboard/analytics→ pricing redirect or locked UI; API returns 403.

  2. Checkout Pro: test card4242...→ webhook upsertsACTIVE→ analytics loads.

  3. Portal cancel: triggercustomer.subscription.updated→ still active until period end if configured → then gate closes.

  4. Wrong price id: unknownstripePriceIdmaps to safe default (proorfree; pick one and document).

  5. Logged out: middleware blocks before plan code runs.

Test webhooks locally withstripe listen(Stripe CLI post).

Regression matrix (manual):

Scenario Expected New user, no Checkout/dashboardOK, Pro routes redirect Active Pro Pro routes 200, API 200 Past due (policy: block) Pro routes redirect, banner on billing Canceled, period ended Same as free Upgraded Pro → BusinessstripePriceIdchange via webhook; teams unlock

Stripe Dashboard “Send test webhook” helps replaycustomer.subscription.updatedwithout full Checkout when debugging gates.


Step 9: Beyond flat plans (usage and credits)

Flat Pro/Business gates cover most dashboards.Usage-based AI billingadds credit balances and metered events on top of subscription tier, covered inusage-based billing in Prisma(Week 12). Order of checks: auth → plan tier → credits remaining.

Env vars for price ids belong in your Vercel checklist alongsideSTRIPE_WEBHOOK_SECRETso staging and production never map the wrong plan after a Stripe Dashboard price rotation.


Common failures

User paid but still sees free

Webhook missingcheckout.session.completedhandler, or Checkout withoutmetadata.userId. Fix sync pipeline (articles 01, 03) before tweaking gates.

Gate uses client-side Stripe.js state

Cause:useEffectreading Checkout success query param only.Fix:Server-side Prisma only.

Canceled users keep Pro forever

Cause:Gating on “subscription row exists” instead ofstatus+currentPeriodEnd.Fix:subscriptionGrantsAccessrules.

Business user blocked from teams

Cause:PRICE_TO_TIERmissing Business price env.Fix:Env checklist on deploy.

Middleware returns 401 on Pro API

Cause:Matcher requires auth but client fetch omits cookies.Fix:Same-originfetchdefaults; CORS only for public APIs.


Pro gates in the kit

Webhooks, portal, middleware, and plan checks are the same entitlements layer every Next.js SaaS reimplements from Stripe docs.

Skryptix AI SaaS Starter Kitincludes:

  • PrismaSubscriptionsync from Stripe webhooks

  • Pro/Business plan gates for analytics, teams, and AI routes

  • Auth.js, middleware, Checkout, and Customer Portal

  • Usage hooks for AI metering

Pro/Business plan gates (analytics, teams) ship withAI SaaS Starter Kit.Get it here.

AI SaaS Starter KitAI SaaS Starter KitProduction-ready SaaS boilerplate with Next.js 16, Auth.js, Stripe subscriptions, Prisma, dashboard, teams, admin CMS, and AI-ready API hooks.$29

FAQ

How do I implement SaaS plan gating in Next.js with Stripe?

Sync subscriptions to Prisma via webhooks, then in Server Components and Route Handlers callauth()and load the user’sSubscriptionrow. Grant access whenstatusisACTIVEorTRIALINGandcurrentPeriodEndis in the future.

What is the difference between auth middleware and plan gating?

Middleware ensures the user is logged in. Plan gating checks whether their subscription entitles them to a feature. Free users should pass middleware to the dashboard but fail Pro-only gates.

Should I check Stripe API on each page load?

No. Use webhook-synced Prisma data for gating. Calling Stripe per request adds latency, rate limits, and failure modes.

How do I handle cancel at period end?

While Stripe keeps the subscription active until the period ends, treatACTIVEwith a validcurrentPeriodEndas entitled. When the webhook setsCANCELEDor the period ends, deny Pro access.

Which Stripe fields should drive feature tiers?

StorestripePriceIdfrom the subscription object and map price ids to plan tiers in env (STRIPE_PRICE_PRO, etc.). Feature flags read the derived tier, not raw Stripe payloads in UI code.

Where should plan gating run if I use both layouts and middleware?

Keep authentication in middleware. Run plan checks in Server Components, Server Actions, and Pro API Route Handlers afterauth(). Middleware does not know your Prisma schema without extra edge calls. Avoid database plan lookups on every asset request.


Written by the team behind Skryptix. We build the AI SaaS Starter Kit so founders can focus on product, not auth and billing infrastructure.