Content Gating
Gate premium content with persisted billing access.
The template can protect a documentation page with the customer's persisted lifetime purchase or active subscription. Checkout redirects do not unlock the page. The docs route reads billing state on the server before it renders the protected content.
Try the Shipped Example
The template includes Premium Example as a working gated page.
Start the app
bun run devVisit without access
Open /docs/customization/premium-example in a signed-out browser or with a new
account. The page title and preview remain visible, but the protected section is
replaced by the paywall.
Complete a test purchase
Run the documented lifetime or subscription billing path, then return to the same URL. The protected section now renders from the server.
Gate a Page
Add gated: true to the page frontmatter:
---
title: Premium Guide
description: A guide for paying customers.
gated: true
---
## Protected guide content
Only customers with persisted billing access can read this section.The frontmatter schema already accepts the gated boolean. No separate route
list or client-side flag is required for a single page.
How the Decision Works
src/lib/gating.ts resolves the requested docs page and reads its frontmatter:
export function isPageGated(slug: string[] | undefined): boolean {
if (!slug || slug.length === 0) return false;
const page = source.getPage(slug);
return page?.data.gated === true;
}The docs route combines that page rule with the server-side purchase reader:
const gated = isPageGated(params.slug);
const purchaseStatus = await getUserPurchaseStatus();
const showPaywall = gated && !purchaseStatus?.hasPurchase;
return showPaywall ? <GatedContent /> : <MDX />;hasPurchase is true only for a persisted lifetime purchase or an active or
trialing subscription. Authentication by itself does not unlock the page.
Do not gate content with a browser-only flag, Checkout success query, or hidden CSS. Keep the access decision on the server and backed by the billing reader.
Gate a Folder
Page frontmatter is the shipped default. To gate a whole folder, extend the
gatedPaths list inside src/lib/gating.ts with slug segments:
const gatedPaths = [
['premium'],
['advanced', 'billing'],
];Each entry matches that folder and its child pages. Keep frontmatter for exceptions that do not belong to a fully protected folder.
Prove the Gate
The billing suites use the shipped premium example as the final reader. The lifetime suite starts with guest Checkout and completes onboarding; the subscription suite signs up before Checkout. Both confirm the paywall first, complete the signed webhook path, and then confirm the protected section is visible:
./coremvp e2e billing:lifetime
./coremvp e2e billing:subscriptionAfter deployment, run the production equivalent against Stripe test mode:
Put the deployed project's NEXT_PUBLIC_SUPABASE_URL and matching
SUPABASE_SERVICE_ROLE_KEY in ignored .env for signup-mode subscription
proof. For lifetime test_capture, put the same TEST_EMAIL_CAPTURE_TOKEN in
ignored local .env and the Vercel Production proof target. Never print these
values, and stop if they do not match the deployed app. Follow
Deploy to Vercel to retrieve the service-role key,
restore the retained sensitive capture token, and verify the deployed capture
mode from a fresh checkout.
export TEST_BASE_URL=https://<project-name>.vercel.app
./coremvp prod e2e billing:lifetime
export PROD_E2E_AUTH_MODE=signup
./coremvp prod e2e billing:subscriptionProduction lifetime proof requires the protected test-capture mode used for guest onboarding. Production subscription proof signs in or signs up before Checkout and does not use that capture path.
Related
- Payments - Configure and prove billing
- Authentication - Understand sessions
- Premium Example - Inspect the shipped gated page
Was this page helpful?