CoreMVP

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 dev

Visit 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:

src/content/docs/premium-guide.mdx
---
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:

src/lib/gating.ts
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:

src/app/docs/[[...slug]]/page.tsx
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:

src/lib/gating.ts
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:subscription

After 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:subscription

Production 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.

Was this page helpful?

On this page