Model gating by subscription plan: make it one enum, not scattered if-else
Routing free users to Haiku and paid to Opus should be one enum and one registry lookup, not if-else scattered across routes. The plan-gating pattern.
"Which model is this user allowed to use?" starts as a one-line check in your chat route. Then you add an embeddings route, a summarize endpoint, a batch job — and the same free-vs-paid logic gets copy-pasted into each, slightly differently each time. A few weeks later a free user is quietly getting Opus from the one route where someone forgot the check, and your margin is bleeding from a place no one is looking. Model access is a billing rule, and billing rules belong in one place.
One enum, one registry
Make the plan an enum and hang everything the plan decides off a single registry entry — including which models it may use and which one it defaults to:
export type PlanId = "free" | "pro" | "scale";
export const PLANS: Record<PlanId, Plan> = {
free: {
monthlyRequestLimit: 25,
allowedModels: ["claude-haiku-4-5"],
defaultModel: "claude-haiku-4-5",
},
pro: {
monthlyRequestLimit: 1_000,
allowedModels: ["claude-opus-4-8", "claude-sonnet-5", "claude-haiku-4-5"],
defaultModel: "claude-opus-4-8",
},
// scale: higher limit, same model access as pro
};
Now "what can a free user run?" is not logic scattered across routes — it is data in one object. Changing the free tier from Haiku to Sonnet is a one-line edit here, and every route that reads the registry picks it up at once. The enum means a typo in a plan name is a compile error, not a runtime surprise.
Resolve, then clamp — in exactly one spot
Every model-calling route does the same two steps: resolve the caller's plan, then honor their requested model only if the plan allows it, otherwise fall back to the plan's default. That clamp is the whole gate:
const requested = parsed.data.model;
const model =
requested && isModelId(requested) && quota.plan.allowedModels.includes(requested)
? requested
: quota.plan.defaultModel;
A free user who hand-crafts a request asking for Opus does not get a 403 — they get Haiku, silently clamped. That is deliberate: the client cannot escalate its own model by editing a request body, and you never have to special-case "they asked for something they can't have." The plan's defaultModel is always a safe answer.
Why this is a billing decision, not an app decision
The reason to centralize is that the gap between models is money. The same request is 5x the cost on Opus as on Haiku, so "which model" is really "how much does this user cost," which is really "does this plan have margin" — all the same question in different clothes. Keeping it in the plan registry means the people who set pricing and the code that enforces it read from the same source of truth, and the enforcement rides along with per-user cost metering and the quota that caps request volume instead of drifting away from them.
Things that bite
- A new route is a new leak. The pattern only holds if every model-calling path resolves the plan and clamps. Make it the one helper every route calls, so forgetting it is hard rather than easy.
- Deleting a model breaks old plans silently. If you retire a model id that a plan still lists in
allowedModelsordefaultModel, requests fail. Treat the registry and the model list as one change. - Do not gate on the client's claimed plan. Resolve the plan server-side from the subscription, never from a value the request sends.
Shipwright — the Next.js and Claude starter kit this blog documents — ships this plan registry and the one clamp every route shares, so model access, quotas, and pricing stay one decision instead of three. See a plan-gated request run in the live demo.