Gating is three checks in three places, and almost every implementation ships one of them. The component hides the button, which is visible, satisfying and enforces nothing. The route handler is the one that actually refuses the request, and it is the one that gets skipped, because skipping it produces software that passes every manual test you would think to run.
The short version. If you only change one thing today, re-check the tier inside every paid route handler, before the expensive work. A hidden button is a courtesy to the user. The endpoint behind it is the product.
In the order they get built in real projects, which is the reverse of the order that is safe.
Necessary, and on its own worth nothing.
Hiding a button is the first thing anyone writes and the easiest to verify: sign in on a free account, the button is gone. That is also why it is where most implementations stop. A hidden button is a presentation choice, not an authorisation decision, and the endpoint behind it is still listening.
The only layer that actually enforces anything.
Every paid route re-checks the tier server-side, before it does the expensive thing. This is the layer that is load-bearing and the layer that gets skipped, because skipping it produces software that passes every manual test. Put the check before the work, not after: a quota charged before validation bills a request that was going to be refused anyway.
For whole surfaces, not individual features.
A dashboard that belongs entirely to a paid tier should redirect before it renders, rather than painting a page of walls. Done in a Server Component this costs one cached read and removes a flash of content the user was never entitled to see.
All six pass a demo. That is why they reach production.
The one that matters. The button is hidden, the feature is reachable, and nothing in your test suite notices because every test drives the UI. Treat the route handler as the gate and the component as a courtesy.
A `const TIERS = ['free', 'pro', 'enterprise']` in the frontend is a second source of truth for a list your billing provider already owns. Add a tier and you ship a deploy to teach the gate it exists; rename one and the comparison silently returns false. Read the ranks from the server with the status.
A comparison against a tier the server has never heard of should deny, not allow. The same applies to a read that throws: a billing lookup that times out must not become a free upgrade for everyone for the duration of the incident.
Plans commonly encode "unlimited" as -1. Compare `count >= cap` against that and your most expensive customers are locked out at the first request, which is the worst possible population to break.
A card expiring is not a cancellation. Instant lockout on `past_due` turns a recoverable billing hiccup into a support ticket and often a churn, and the dunning window exists precisely so you do not have to decide this yourself. Grant grace deliberately rather than by accident.
A gate whose every branch demands a registered customer can never be passed by somebody who has not paid yet, so the trial it advertises is unreachable. If a tier is offered on the pricing page, make sure a brand-new signed-in account can actually get into it.
Nothing here needs a library. The whole mechanism is a rank comparison plus a cached read, and it is worth seeing it plainly before deciding whether to own it:
// Ranks come from wherever your plans are defined, NOT from a
// hardcoded array in the client. An unknown tier must deny.
function hasAccess(entitlement, minTier, ranks) {
const need = ranks[minTier];
const have = ranks[entitlement.tier];
if (need == null || have == null) return false; // fail closed
return have >= need;
}
// The enforcing layer. Note the check precedes the work.
export async function POST(req) {
const email = await identify(req);
if (!email) return new Response('Unauthorized', { status: 401 });
const ent = await getEntitlementCached(email); // 60s TTL, tag-invalidated
if (ent.status === 'past_due' && !GRACE) {
return new Response('Payment required', { status: 402 });
}
if (!hasAccess(ent, 'pro', ent.ranks)) {
return Response.json({ upgradeUrl: pricingUrl(email) }, { status: 402 });
}
return doTheExpensiveThing(await req.json());
}Two details in that sketch carry most of the bugs. The ranks are read from the entitlement rather than declared locally, so adding a plan does not require a deploy to teach the gate it exists. And the 402 carries an upgrade URL, because a gate that refuses without offering the way through it is just an error.
Spark Stack ships lib/spark-stripe/ with the three layers already written against a SparkPay backend. The route guard is a wrapper, so the gate is one import rather than a block of code in every handler:
import { guards, getLimit, hasReachedLimit, nextTier }
from '@/lib/spark-stripe/sparkstripe-server';
// guards is built FROM plans.json and keyed by tier slug, so a renamed
// or dropped tier is a compile error here rather than a silent false.
export const POST = guards.pro(async (req, ctx, status) => {
const cap = getLimit(status, 'exports'); // the CAP, not what is left
if (hasReachedLimit(status, 'exports', used)) { // knows -1 means unlimited
return Response.json({ upgrade: nextTier(status) }, { status: 402 });
}
return doTheExpensiveThing(await req.json());
});| Export | What it does |
|---|---|
isAllowed(status, opts) | The single predicate. Checks verification, then past_due, then minTier, then falls back to the paywall flag. |
hasAccess(status, minTier) | Rank comparison using the ranks the server sent, not a local enum. Unknown tier returns false. |
guards.<tier>(handler) | Route-handler wrappers generated from plans.json, so a renamed tier breaks the build instead of the gate. |
guard(handler) | The same wrapper with no tier requirement: any registered user with current access. |
gatePage(email, opts) | Server Component guard for a whole route, so nothing paid paints first. |
SparkGate | The component wall. Takes the status it should judge, so it cannot disagree with the route. |
getLimit(status, key) | The plan CAP for a key, not what is left. -1 means unlimited. |
hasReachedLimit(status, key, count) | The comparison that already knows -1 is a sentinel. |
nextTier(status) | The tier above the current one, for an upgrade prompt that names the right plan. |
The enforcing half lives in sparkstripe-server.ts, which deliberately has no 'use client': that is where getStatusCached, the guards and the webhook come from. SparkStripe.tsx is the React half and imports from it; that direction is safe and the reverse is a bug. Do not re-export the server half through the client one to make the import paths uniform. It turns those functions into client references, and a guarded route built that way compiles clean, passes CI, deploys, and then throws on every request with nothing reporting it.
In the route handler, always. Add a Server Component check for pages that are entirely paid, and a component wall for mixed pages. The component is for the user experience and the route handler is for the entitlement, and only one of those is enforcement.
They solve a neighbouring problem, not this one. A flag answers "is this code path on", which is a deploy-time decision for everyone; a tier answers "may this customer do this", which changes per request and has to be right the moment a payment succeeds or a card fails. Flags are also usually evaluated client-side, which is the wrong side of the wall. Use flags to roll a feature out and tiers to decide who may use it.
Cache the entitlement read per user with a short TTL and a tag you can invalidate, then invalidate it on the webhook that changed the subscription. Sixty seconds is usually fine, because the one case where staleness is unacceptable, a customer who just paid, is exactly the case the webhook tells you about.
That is a product decision and you should make it on purpose. The defensible default is a grace period: keep access while the provider retries the card, warn in the UI, and only gate once dunning is exhausted. Locking out on the first failed charge punishes a customer who is still trying to pay you.
You need a read path your UI can call on every render without hitting your billing provider, so something has to hold the answer. Whether that is a subscriptions table you own and reconcile, or a hosted layer you query, is the real choice: the table is not optional, but owning it is.
On its SparkPay pricing page, which is the page checkout charges from and the only place the figure is maintained. Quoting it here would mean a second number that goes stale the day a plan changes.