[Guide] Build a Multi-tenant SaaS Settings App (Overridable Constants) - implementation notes
The Multi-tenant Settings guide makes a very opinionated claim: tenant configuration should be a *scope contract*, and per-tenant overrides should be treated like normal routing tools rather than special-case config plumbing. I like that it makes tenant/plan/posture visible (data-tenant, data-plan, cache lane, render cadence), but I'm curious how people keep scope from turning into an unowned bag of flags.
How do you decide what belongs in tenant scope vs in global route state (tenantId/panel/overlay) vs in a screen document? Do you treat plan tier (free|pro|enterprise) as a scope baseline, a route key, or a server-derived input that seeds scope once? How do you render evidence for overrides so a screenshot can explain "why is this toggle disabled"? If you allow local drift (render fetch + no perfect sync), what is your reconciliation rule when the server disagrees with local overrides?
Comments (20)
Back to latestTreating tenant config as scope made the whole app calmer for us.
Once the baseline is shared, panels stop inventing per-page defaults.
We kept tenantId + panel + overlay in route state and everything else in scope.
That matched what users expect: navigation changes panel/overlay, tenant switch changes baseline.
A concrete pattern we used: compute a derived list of override keys and render it as evidence.
tsx
export const TenantScope = createScope('tenant');
export function TenantScopeProvider({ tenantId, children }: { tenantId: string; children: React.ReactNode }) {
const scope = useScope(TenantScope, {
tenantId,
features: { invites: true, apiKeys: false },
limits: { seats: 10, projects: 3 },
defaults: { currency: 'USD', timezone: 'UTC' },
derived: { overrideKeys: [] as string[], scopeVersion: 'v1' },
});
scope.write('derived', {
...scope.read('derived'),
overrideKeys: Object.keys(scope.read('features')).filter((k) => scope.read('features')[k]),
});
return <section data-tenant={tenantId} data-overrides={scope.read('derived').overrideKeys.join(',')}>{children}</section>;
}
That data-overrides string made support debugging possible.
Counterpoint: scope can become a dumping ground if it's too easy to add keys.
We had to treat scope keys like public API (reviewed, documented, and rendered as evidence).
Same concern. We started requiring one visible evidence line per key (either in the shell or a debug panel).
If you can't make the key legible, it's probably not a real contract key.
Plan tier as route key worked for us because it made switching tiers during demos/test flows easy.
We still treated it as a baseline that should rarely change in real usage.
We seeded plan tier from the server once, then route truth owned the rest (so UI couldn't oscillate).
Also: render data-plan and data-tenant always. It's the cheapest evidence you can add.
The cache lane / render cadence posture was surprisingly useful.
We used cacheLane=bypass for billing/security panels and cacheLane=cache for profile to reduce churn.
Reconciliation rule: we store a local overrideRevision and only apply server values if serverRev >= localRev.
Otherwise you get the "toggle flips back" feeling even when the write succeeded.
We also logged the last override intent as evidence (lastOverride={key,value,reason}).
It turned "why did this change" into an answer instead of an argument.
Docs tie-in: the scope contract idea is basically "define the baseline" like route shells do.
Related: State Management (Best Practices) and Routing and Navigation.
A misunderstanding I had: I thought "overridable constants" meant hard-coded feature flags.
In practice it's more like: define defaults, allow overrides, and render which overrides won.
We made overrides safe by limiting the override surface (no arbitrary keys).
If you can override anything, you end up with per-tenant snowflakes nobody can support.
We rendered a list of active override keys in the billing panel itself (not just debug).
It reduced customer confusion because they could see why a feature wasn't available on their plan.
Counterpoint: if your overrides change frequently, storing derived overrideKeys can get stale.
We solved it by making override derivation the first step after any scope write (single call site).
Yep, derivation discipline matters more than the exact shape of scope.
If you can't guarantee derive, you'll get drift no matter what you call it.
We treated tenant switching as identity change and used a remount boundary for the settings shell.
It avoided subtle bugs where old tenant docs were still mounted and reading stale scope.
If you let the server disagree with local overrides, you need a visible lane for it (optimistic vs confirmed).
Otherwise users think the UI is broken when values bounce.
We also stored a derived scopeVersion string so we could tell which baseline we were on after deployments.
It helped during migrations where scope shape changed.
One thing I'd add: a "why" string per override (plan vs tenant vs admin).
A boolean alone isn't enough evidence when someone asks support why something is disabled.
We kept timezone/currency defaults in scope and it prevented formatting drift across panels.
If each panel formats time differently, customers assume it's data corruption.
The overall posture worked best when we treated scope as boring configuration, not reactive state.
Once it's boring, it's easier to version, test, and support.