How to gate premium pages for paid users in Next.js
Protect a Next.js page and API with verified Supabase identity and durable, revocable paid access.
To gate a premium page, verify the current user on the server and check a current access record before rendering. Apply the same check to every API or Server Action that returns premium data. A browser flag, a Checkout success URL, or a claim copied into a long-lived session cannot by itself tell you whether access has since been revoked.
This example protects one page and one API route using the same Supabase user and durable billing_subscriptions rows created by how to add Stripe billing to Next.js. Its signed webhook writes the row; the gate reads it. No separate seeded entitlement table is required.
Before you start
Complete how to add authentication to Next.js and the Stripe billing guide, including its database migration, lib/billing/access.ts, customer mapping, Checkout caller and verified webhook writer. Use the same Supabase project and database for all of them. The guide's paidAccess() verifies the server identity, reads the signed-in user's own billing_subscriptions rows under RLS, and grants access only for an active row with an unexpired access_until. The server handles billing writes; the browser cannot set its own paid status.
The example policy covers a single recurring card price and no trials. A signed event and the trusted writer update the row when Stripe reports subscription changes; the gate has no payment-provider API key and does not trust a JWT paid flag. For another billing model, define and implement its own access and cancellation policy first.
1. Connect the billing reader
Use the exact lib/billing/access.ts implementation from the prerequisite. It returns unauthenticated, allowed, denied, or unavailable on a fresh request. Do not cache this result across users or requests: after a cancellation event is processed, a new request must see the updated row. If the database lookup fails, deny access rather than converting the error to a paid result. Supabase documents verified claims and row-level security.
2. Protect the page before rendering
import { redirect } from 'next/navigation';
import { paidAccess } from '@/lib/billing/access';
export default async function PremiumPage() {
const access = await paidAccess();
if (access === 'unauthenticated') redirect('/login');
if (access === 'denied') redirect('/pricing');
if (access === 'unavailable') {
return <main><h1>Access temporarily unavailable</h1><p>Please try again.</p></main>;
}
return <main><h1>Premium workspace</h1><p>Your paid content is available.</p></main>;
}Create /login and /pricing or change these destinations to routes your app owns. Load real premium content only after the check. Do not put a client redirect around already-rendered private HTML.
3. Protect the API separately
A user can request an API directly, even when the page is hidden. Call the same server reader at the data boundary:
import { paidAccess } from '@/lib/billing/access';
export async function GET() {
const access = await paidAccess();
if (access === 'unauthenticated') {
return Response.json({ error: 'Sign in required' }, { status: 401 });
}
if (access === 'denied') {
return Response.json({ error: 'Paid access required' }, { status: 403 });
}
if (access === 'unavailable') {
return Response.json({ error: 'Access temporarily unavailable' }, { status: 503 });
}
return Response.json(
{ message: 'Premium data' },
{ headers: { 'Cache-Control': 'private, no-store' } },
);
}Replace the example response with your real data read after the check. Apply the same rule to each Server Action and other endpoint that exposes premium data or changes it. For user-owned resources, additionally check that this user owns or belongs to the requested resource. The gate does not make every row in your database accessible to every subscriber.
4. Verify admission and revocation
- As an anonymous visitor, request
/premiumand/api/premium: the page redirects to sign-in and the API returns401without premium data. - Sign in as a user without a subscription: the page redirects to pricing and the API returns
403. Attempt a billing row write with that user's normal client; the database must reject it. - Complete sandbox Checkout as that user. Confirm the signed webhook updated the matching
billing_subscriptionsrow, then reload the page and API: they should allow access. A second user must still be denied and must not be able to read the first user's billing row. - Cancel through the portal from the billing guide and inspect the subscription event and row. For immediate cancellation, fresh page and API requests with the same signed-in session must deny access after the writer records the change. For scheduled cancellation, verify access remains until
access_untiland then ends. A response already delivered to a browser cannot be recalled. - Resend a completed event and verify the same event ID does not repeat writes or produce duplicate effects. Send an invalid signature through your test harness and verify no billing state changes. The Stripe sandbox testing walkthrough details the listener and evidence to inspect.
If the paid user sees the temporary-unavailable message, inspect the server error and database policy rather than granting access on error. If cancellation arrives asynchronously, access remains based on the last stored state until your trusted writer processes and reconciles it; monitor delivery failures and define recovery. See Stripe webhooks for delivery and retry behavior.
CoreMVP's billing Docs describe its connected payment-to-access path, including verified webhooks and persisted billing state. That removes the need to design the entire foundation from scratch; customers still choose their own product-specific access and cancellation policy.