React Forum
Blog & Guide Responses
Jan 22, 2023 · 04:00 PM

[Guide] Build a Multi-tenant SaaS Settings App (Overridable Constants) - implementation notes

Started by Theo Bennett
guidesettingsmulti-tenantscope contractoverridable constantsplan tierevidencecache lane

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.

Read the guide

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 latest
Marisol Vega
Jan 27, 2023 · 06:12 PM

Treating tenant config as scope made the whole app calmer for us.

Once the baseline is shared, panels stop inventing per-page defaults.

Avery Chen
Feb 04, 2023 · 07:22 AM

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.

Dmitri Kline
Feb 16, 2023 · 08:30 PM

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.

Keira Santos
Feb 28, 2023 · 05:14 AM

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

Theo Bennett
Mar 02, 2023 · 04:40 PM

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.

Grace McCoy
Mar 18, 2023 · 11:08 AM

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.

Jasper Nguyen
Apr 02, 2023 · 10:41 PM

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.

Nina Kapoor
Apr 21, 2023 · 03:05 AM

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.

Owen Price
May 09, 2023 · 02:12 PM

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.

Camille Duarte
May 26, 2023 · 06:26 AM

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.

Lina Ortega
Jun 18, 2023 · 07:55 PM

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.

Benji Rios
Jul 05, 2023 · 02:10 AM

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.

Amina Farouk
Jul 22, 2023 · 10:14 AM

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.

Tomas Rivera
Aug 11, 2023 · 09:33 PM

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.

Sofia Valdez
Sep 02, 2023 · 07:45 AM

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

Dmitri Kline
Sep 05, 2023 · 07:10 PM

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.

Priya Liao
Oct 01, 2023 · 04:04 AM

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.

Evan Brooks
Oct 27, 2023 · 12:12 PM

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.

Imani Brooks
Nov 18, 2023 · 08:20 PM

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.

Rowan Pierce
Dec 29, 2023 · 06:06 AM

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.

Talia Moss
Dec 30, 2023 · 12:01 PM

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.

Salma Qureshi
Dec 31, 2023 · 03:33 AM

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.