Boring Docs
Paywall — One Boolean, Two Plans
requirePaidUser, PaywallGate, guarded routes, vault, claim-repo.
In this guide
Paywall
Here's the situation you're going to be in: payments work (you did 06-payments, right?), money lands, webhooks fire — and now you need to actually lock things. Pages, API routes, downloads. This doc is the whole locking system, and it's smaller than you think: one function, one component, two HTTP codes. Let's walk it together.
Meet your one function: requirePaidUser()
Everything starts and ends in libs/paywall.js. Call await requirePaidUser() and you get back { session, user, hasAccess, planType, isLifetime, isMonthly }. That's it. That's the entire access-control API of your SaaS. Pages branch on it, APIs map it to status codes, components render on it.
Now, read the return shape carefully, because it encodes three different humans:
- No session, or a stale one (cookie points at a deleted user — remember the ghost from
04-auth?):userisnull,hasAccessfalse,planType: "free". The function doesn't throw; it tells you the truth and lets the caller decide what "no" looks like. - Logged in, never paid:
hasAccessfalse,planType: "free". - One-time buyer:
hasAccesstrue,planType: "lifetime",isLifetimetrue. Pay once, yours forever — the system knows the difference between this person and a subscriber. - Subscriber:
hasAccesstrue,planType: "monthly",isMonthlytrue.
Notice what it checks: the user flag and completed order rows (by user ID and by email — because people pay with a different email than they signed up with constantly, and you don't want to punish them for it). Belt and suspenders, on purpose. A subscription is detected by sub_-prefixed refs or recurring pricing; everything else with a completed order reads as lifetime.
Pages: branch, don't bounce-loop
Open app/(pages)/vault/layout.js — it's the reference implementation, thirteen lines of logic. It calls the function, checks admin override (isAdmin also gets in — your team needs to see what buyers see), and passes hasAccess down. The layout is force-dynamic because access is per-request truth, never cached HTML. Cache a "locked" page and buyers see a paywall after paying; cache an "open" page and strangers see the vault. Both have happened to real founders. Don't cache access decisions. Ever.
Then the page itself renders <PaywallGate hasAccess planType ...> with two faces: unlocked members see their content under a slim green "Pro Member Access Unlocked" bar (with a "Manage Billing →" link, because the second-most common support ticket after login issues is "how do I cancel"), and everyone else gets the blurred preview with the lifetime offer card. Show the goods blurred behind the gate — conversion 101: people buy what they can almost touch.
APIs: 401 vs 403, and why the difference is the feature
Look at paidOrError() in app/api/board/route.js — steal this helper verbatim for every paid route you ever write:
- No session or stale session → 401. "I don't know who you are — log in." The client sends them to
/auth. - Logged in but free → 403. "I know exactly who you are, and the answer is no — subscribe." The client shows the paywall.
Two codes, two client behaviors, zero ambiguity. The classic beginner mistake is 401 for everything, and then your frontend can't tell "logged out" from "freeloader" — users get login screens when they need pricing pages. Keep them split and your UI always does the obviously-right thing.
And remember the thin-route law from CONVENTIONS.md: validate → paidOrError() → one provider call → JSON. Your POST handler after the gate should be boring. If it's over ~40 lines, logic leaked upward — push it into a provider.
Testing it locally (two emails, five minutes)
You already have the tools from 04-auth: log in as [email protected] (free — you should hit every gate), then as [email protected] (access pre-granted — every gate opens). Walk your locked pages with both. Then the nasty cases: revoke access mid-session (flip hasAccess in the DB directly) and confirm the next request locks — sessions re-check the row every call, nothing is cached. And test the stale ghost again: delete the user, keep the cookie, hit a paid API — 401, not a crash, not a loop.
Add your own locked area in five minutes
New premium feature? Don't improvise — clone the vault pattern:
- Layout gate. Create
app/(pages)/pro/layout.js: callrequirePaidUser(), computehasAccess(plusisAdminbypass so your team can see it), render nav +{children}. Mark itexport const dynamic = "force-dynamic". Copy vault's layout and change the nav — seriously, copy-paste, don't retype. - Page gate. In the page, render
<PaywallGate hasAccess={...} planType={...} title="..." subtitle="..." preview={<YourPreview/>}>with your real content aschildren. Thepreviewprop is the blurred teaser — pass a trimmed version of the actual UI, not a stock illustration. People buy what they almost touched. - API gate. Any route serving the feature's data gets
paidOrError()as its second line. No exceptions, no "it's just a list endpoint" — list endpoints leak entire catalogs. - Test both humans.
[email protected]sees the gate;[email protected]sees the goods. Then revoke mid-session and confirm the lock snaps shut on the next request.
Total: one layout, one component usage, one helper call. If your new area needs anything fancier than this, stop — you're building around the system instead of with it. Reread the three faces above and fit your feature into them; the shapes cover every SaaS I've ever seen.
The mistakes you'll be tempted to make
Caching the gate. Said above, saying again: force-dynamic on locked layouts. Your CDN is not your bouncer.
Client-side-only checks. Hiding a button is decoration. Every paid API route enforces server-side — the button is courtesy, the route is the lock. If your feature works by calling the API directly with curl and no session, you don't have a paywall, you have a suggestion.
Forgetting the admin path. You need to see the vault to support it. isAdmin bypass exists — use your own admin account for testing, not a fake buyer row you'll forget to delete.
That's the system. One function, one component, two codes. Next: 08-emails — the mail your app sends when all this works.