Dev.to Security 🔐 Cybersecurity 👁 0 📖 11 min read

Free vs Pro Feature Gating in Next.js App Router (2026): Pages, Server Actions, and API Routes

By Jake Mcdonald, founder of ShipEmber · November 10, 2026 · About a 9-minute read · Checked against the Next.js, Auth.js, and Stripe docs I'm Jake Mcdonald. Once a SaaS has a Free plan and a Pro plan, every new feature

By Jake Mcdonald, founder of ShipEmber · November 10, 2026 · About a 9-minute read · Checked against the Next.js, Auth.js, and Stripe docs

I'm Jake Mcdonald. Once a SaaS has a Free plan and a Pro plan, every new feature comes with the same quiet question: who is allowed to use this, and where do I check? The first answer most of us reach for is to hide the button for Free users and maybe add a redirect in middleware. It looks done. It isn't.

This post is the gating setup I use in Next.js App Router apps: one server-only helper that reads the plan from the database, a requirePro() wrapper for pages and Server Actions, a 401/403 check for route handlers, and a short list of places where a check looks like protection but isn't. Everything here was checked against the Next.js authentication guide, the Auth.js protecting resources docs, and Stripe's fulfillment docs on November 10, 2026.

The rule: every entry point checks for itself

A Next.js app has more doors than it looks like. A Pro feature can be reached through:

  • The page that renders it (a Server Component).
  • The Server Action that a form or button on that page calls.
  • The route handler under app/api/... that a client component, a script, or curl can hit directly.

The Next.js docs say it plainly: treat Server Actions and Route Handlers "with the same security considerations as public-facing API endpoints." Hiding a button doesn't stop anyone from calling the action behind it, and a page redirect doesn't protect an API route that lives at a different URL. So the rule I follow is simple: each of those three entry points checks the plan itself, on the server, using the same helper.

Step 1: Read the plan from your database, not from the session cookie

The plan should live in your database, written by your Stripe webhook handler, and be read fresh on the server whenever you make a decision with it. Two reasons:

  • Plans change without the user doing anything. A card fails, a subscription is canceled in the customer portal, a trial ends. Stripe tells your webhook, your webhook updates the row, and the very next request should see it. If you copied the plan into a JWT or cookie at sign-in, the user keeps Pro until that token is refreshed.
  • It's the one source of truth. If the page, the action, and the API route all read the same row through the same function, they can't disagree.

Here's the helper. It assumes Auth.js v5 with the auth() function exported from your auth.ts file, and a session callback that puts the user's ID on session.user.id (with the database session strategy, that's session.user.id = user.id in the session callback). The schema is the usual one: a User with a plan of FREE or PRO, and an optional Subscription row with the Stripe status.

// lib/billing.ts
import "server-only"
import { cache } from "react"
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"

export function hasProAccess(plan: string, status?: string | null) {
  if (plan !== "PRO") return false
  if (!status) return true // manual grant, no Stripe subscription
  return status === "ACTIVE" || status === "TRIALING"
}

export const getViewer = cache(async () => {
  const session = await auth()
  const userId = session?.user?.id
  if (!userId) return null

  const user = await prisma.user.findUnique({
    where: { id: userId },
    select: {
      id: true,
      email: true,
      plan: true,
      subscription: { select: { status: true } },
    },
  })
  if (!user) return null

  return {
    id: user.id,
    email: user.email,
    isPro: hasProAccess(user.plan, user.subscription?.status),
  }
})

A few notes on why it looks like this:

  • import "server-only" makes the build fail if a Client Component ever imports this file, which is exactly what you want for anything that touches the database.
  • React's cache means a page and its child components can all call getViewer() in one render and only hit the database once. The Next.js docs point out that cache only dedupes within a render pass; in Server Actions and Route Handlers it does nothing and the function just runs every time. That's fine. It's still correct, just not memoized.
  • It returns a small object (ID, email, isPro), not the whole user row. That's the "Data Transfer Object" idea from the Next.js guide: only pass along what callers need.
  • PAST_DUE counts as Free in this version. Some apps give a grace period instead. That's a product choice, and this function is the one place to make it.

Step 2: A requirePro() helper for pages and Server Actions

Pages and Server Actions want the same behavior: no session goes to login, a Free user goes to pricing, a Pro user gets through.

// lib/billing.ts (continued)
import { redirect } from "next/navigation"

export async function requireUser() {
  const viewer = await getViewer()
  if (!viewer) redirect("/login")
  return viewer
}

export async function requirePro() {
  const viewer = await requireUser()
  if (!viewer.isPro) redirect("/pricing?upgrade=1")
  return viewer
}

redirect() from next/navigation throws (a NEXT_REDIRECT error) and stops rendering, and its return type is never, so TypeScript knows viewer is non-null after those lines. One gotcha from the Next.js docs: because it works by throwing, call redirect() outside of any try/catch, or your catch block will swallow it.

Gating a page

// app/dashboard/reports/page.tsx
import { requirePro } from "@/lib/billing"

export default async function ReportsPage() {
  const viewer = await requirePro()
  const reports = await getReportsFor(viewer.id)
  return <ReportsTable reports={reports} />
}

Because the page calls auth() (which reads cookies), it renders per request, not at build time. That matters: if Pro content ends up in a statically rendered page, it's generated once and served to everyone.

If a page has Free and Pro sections, gate the Pro section instead of the whole page. An async Server Component can call getViewer() and render an upgrade card instead of the Pro content. Thanks to cache, it won't cost another database query.

Gating a Server Action

// app/dashboard/reports/actions.ts
"use server"
import { requirePro } from "@/lib/billing"

export async function exportReportsCsv() {
  const viewer = await requirePro()
  // Only reached by signed-in Pro users
  return buildCsvFor(viewer.id)
}

The check lives inside the action, not just on the page that shows the button. If the page already checked, this looks redundant. It isn't: the action is its own endpoint, and anyone with a Free account can call it without going through your UI.

One related trap from the Next.js docs: don't rely on a layout for the check. Because of partial rendering, layouts don't re-render on every navigation, and a layout that hides {children} doesn't stop the nested page or its Server Actions from running. Do the check in the page, the component, and the action that actually touch the data.

Step 3: Gate API routes with 401 and 403, not redirects

Route handlers are usually called by fetch, scripts, or other services, so a redirect to /login is the wrong answer. Return a status code, the same two-tier way the Next.js docs show: 401 when there's no session, 403 when there is a session but not enough access.

// app/api/reports/export/route.ts
import { getViewer } from "@/lib/billing"

export async function GET() {
  const viewer = await getViewer()
  if (!viewer) {
    return Response.json({ error: "Sign in required" }, { status: 401 })
  }
  if (!viewer.isPro) {
    return Response.json({ error: "Pro plan required" }, { status: 403 })
  }

  const csv = await buildCsvFor(viewer.id)
  return new Response(csv, {
    headers: { "Content-Type": "text/csv" },
  })
}

Your client code can then handle a 403 by showing the upgrade prompt, and nothing Pro-only is ever sent to a Free user.

The one route that should not have this check is your Stripe webhook. It's called by Stripe, not by a signed-in user, and it proves itself with a signature instead of a session. Keep it out of your auth checks and out of your proxy matcher, or Stripe will get redirects and count them as failed deliveries.

Why middleware (proxy) and UI-only checks aren't enough

As of Next.js 16, middleware.ts has been renamed to proxy.ts. Auth.js supports it directly: export { auth as proxy } from "@/auth", or export { auth as middleware } on older Next.js versions. It's a nice way to bounce signed-out users away from /dashboard before anything renders. I use it. But both the Next.js docs and the Auth.js docs say not to rely on it alone, and there are concrete reasons:

  • It's meant for optimistic checks. Proxy runs on every matching route, including prefetches, so the Next.js docs recommend reading only the session cookie there and skipping database checks. That means it can tell "signed in" from "signed out," but it's the wrong place to decide "Pro" from "Free" against fresh data.
  • Matchers have gaps. Common matchers exclude /api on purpose. That's correct for your webhook, but it also means your API routes are not covered by the proxy at all.
  • It has been bypassed before. CVE-2025-29927 let attackers skip middleware entirely by sending an internal x-middleware-subrequest header, which bypassed any auth check that lived only in middleware. It was fixed in Next.js 15.2.3, 14.2.25, 13.5.9, and 12.3.5, and Vercel-hosted apps weren't affected. But apps that also checked on the server were fine either way. Defense in depth is the real lesson.

UI-only checks are weaker still. {isPro && <ExportButton />} is good UX, so keep it. But it's a display choice, not access control. The Next.js docs say it directly: client-side UI restrictions alone aren't sufficient, and Server Actions called from those components need their own checks.

The Checkout success URL is not a receipt

This is the gating mistake I see most often in Stripe tutorials: the success page reads ?success=true (or just the fact that the user landed there) and flips them to Pro. Anyone can type that URL.

Stripe's docs are clear that webhooks are required: "You can't rely on triggering fulfillment only from your Checkout landing page, because it's not guaranteed customers visit that page." Someone can pay and lose their connection before the page loads. And for subscriptions specifically, Stripe says webhook fulfillment is required because the subscription's state keeps changing after Checkout.

So the flow I use is:

  1. Your webhook handler (verified signature, idempotent) is the thing that writes plan and subscription.status. That's covered in my Stripe webhook idempotency post.
  2. The success page is just a page. It calls getViewer() like every other page and shows "You're on Pro" if the database says so, or "Payment received, activating your account..." if the webhook hasn't landed yet.
  3. If you want instant activation, Stripe's docs also recommend triggering fulfillment from the landing page: put {CHECKOUT_SESSION_ID} in your success_url, retrieve that Checkout Session on the server with your secret key, check it belongs to the signed-in user and is actually paid, and then call the same idempotent sync function your webhook uses. Never trust the query string by itself.

Worth knowing: with a webhook endpoint listening for checkout.session.completed, Stripe's docs say Checkout waits up to 10 seconds for your server to respond before redirecting the customer (that wait doesn't apply to endpoints registered in a Stripe organization account). With a fast webhook, the plan is often already updated by the time they land. (When testing locally with the Stripe CLI, Checkout redirects immediately, so you'll see the "activating" state more often in dev.)

A quick test plan

Before you ship a new Pro feature, run these. They take ten minutes:

  1. Signed out: open the Pro page in a private window. You should land on /login.
  2. Free user: open the Pro page. You should land on /pricing?upgrade=1.
  3. API, no cookie: curl -i https://your-app.com/api/reports/export. You should get a 401.
  4. API, Free user: call the same route from the browser console while signed in as a Free user. You should get a 403.
  5. Server Action as Free: temporarily render the Pro button for everyone and click it as a Free user. You should get redirected and nothing should be exported. Then remove the temporary change.
  6. Downgrade: cancel a test subscription in the customer portal. Once the webhook lands, the next request should treat the user as Free. No sign-out needed.
  7. Fake success: visit your success URL by hand with a made-up session_id. Nothing should change.

Why choose ShipEmber

None of this needs a library. It's a few dozen lines, and if you want to own every one of them, this post should get you there. I built ShipEmber because I kept rewriting this same plan helper, webhook sync, and gated dashboard for every project.

ShipEmber is a Next.js App Router starter with that loop already working: Auth.js v5 sign-in (Google, GitHub, and email magic links), Stripe Checkout, a customer portal route, signed and idempotent webhooks that keep Free/Pro in the database, Prisma with Postgres, and a dashboard that checks the plan on the server. You rename it, add your keys, and spend your time on the Pro features themselves instead of the plumbing around them.

To be honest about where it doesn't fit: if you need teams, organizations, per-seat billing, or role-based permissions from day one, a bigger kit will serve you better. ShipEmber is deliberately small, built for one product with a Free plan and a Pro plan.

It's a one-time payment, $49 right now on the site (shown there as the launch price, with $79 listed as the regular price), and you can use it in unlimited personal and client projects. You get 14 days to ask for a refund if it doesn't work as described.

Prefer watching over reading? There's a short walkthrough:

Watch the ShipEmber walkthrough on YouTube

See pricing or Get ShipEmber, $49.

Related: Stripe Webhooks in Next.js: Idempotency, Out-of-Order Events, and Retries · Stripe Customer Portal in Next.js · Next.js + Stripe + Auth.js setup checklist

Doc note: authorization guidance, redirect() behavior, cache scope, and the proxy rename checked on nextjs.org; auth() and proxy usage checked on authjs.dev; landing-page and webhook fulfillment guidance checked on docs.stripe.com/checkout/fulfillment; CVE-2025-29927 versions checked on the GitHub advisory GHSA-f82v-jwr5-mffw. All on November 10, 2026.

— Jake Mcdonald, Springfield, IL

Originally published on shipember.com.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.