Guide 22
Build a Multi-tenant SaaS Settings App
This guide builds a multi-tenant settings experience where tenant configuration is a scope contract and “overridable constants” are treated as a normal routing tool.
We’ll build a stable file-router map for settings pages, then layer component-router motion (tabs, overlays, editors) without rewriting URLs. Tenant and plan configuration flows through scope, not prop threading.
Keep these references open while you build:
Architecture Plan (Detailed, Route-first)
Requirements
- Tenant switcher (choose active tenant).
- Settings sections:
- Profile
- Billing
- Security
- Global “plan tier” that influences defaults (free/pro/enterprise).
- Overridable constants per tenant (feature flags, limits, UI posture keys).
- Render-visible evidence:
- active tenant id
- plan tier
- which overrides are active
Non-requirements
- Perfect backend sync. We’ll render-fetch tenant settings and allow local drift.
- Strong data normalization. We’ll use documents and derived bundles so views are always ready.
- Permissions matrix. We’ll leave hooks for security scope later.
Route Map + Motion
File router map:
app/
settings/
page.tsx
settings/[tenantId]/
page.tsx
settings/[tenantId]/billing/
page.tsx
settings/[tenantId]/security/
page.tsx
Component router motion inside the tenant settings shell:
panel: "profile" | "billing" | "security"overlay: "none" | "invite" | "limits" | "apikeys"
URL as hint:
- We use
[tenantId]as the initial hint. - The global route store becomes the winner after first read and renders
data-tenant.
Contract Surfaces
Global route store keys (useGlobalRoute("settings", ...)):
tenantId: stringpanel: "profile" | "billing" | "security"plan: "free" | "pro" | "enterprise"posture: { renderCadence: "warm" | "hot"; cacheLane: "bypass" | "cache" }
Tenant scope keys (provided via useScope):
features: Record<string, boolean>limits: { seats: number; projects: number }defaults: { currency: string; timezone: string }- Derived keys:
derived.overrideKeys: string[]derived.scopeVersion: string
Testing Plan
- Snapshot each panel with tenant scope evidence.
- Route-flow tests for tenant switch:
/settings/t1→ switch tot2→data-tenantchanges → derived overrides update.
Step 1: Shell + Global Route Contract
import { createSurface, routeFile, createAppState } from "react";
const app = createAppState({ app: "settings" });
createSurface("#app").render(routeFile("/settings/t1", { state: app }));
Global route store:
import { useGlobalRoute } from "react";
export function SettingsShell({ children }: { children: React.ReactNode }) {
const route = useGlobalRoute("settings", {
tenantId: "t1",
panel: "profile" as "profile" | "billing" | "security",
plan: "pro" as "free" | "pro" | "enterprise",
posture: { renderCadence: "warm" as "warm" | "hot", cacheLane: "bypass" as "bypass" | "cache" },
});
return (
<section
data-tenant={route.read("tenantId")}
data-plan={route.read("plan")}
data-cache-lane={route.read("posture").cacheLane}
data-render-cadence={route.read("posture").renderCadence}
>
{children}
</section>
);
}
Step 2: Tenant Scope (Overridable Constants as a First-class Tool)
We treat tenant configuration as a scope contract so panels don’t need to thread props.
import { useScope, createScope } from "react";
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, export: true },
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 (
<TenantScope.Provider value={scope}>
<div data-tenant-scope={scope.read("derived").scopeVersion} data-overrides={scope.read("derived").overrideKeys.join(",")}>
{children}
</div>
</TenantScope.Provider>
);
}
Step 3: Tenant Settings Document (Per-tenant Screen State)
Each tenant settings page keeps a local document for drafts + derived summaries.
import { useLiveState, useGlobalRoute } from "react";
export function useTenantSettingsDoc(tenantId: string) {
const route = useGlobalRoute("settings", { tenantId, panel: "profile", plan: "pro", posture: { renderCadence: "warm", cacheLane: "bypass" } });
const doc = useLiveState(`settings:${tenantId}`, {
source: { tenantId, lastFetchedAt: 0 },
drafts: { displayName: "", timezone: "" },
derived: { summary: "", dirty: false, panel: route.read("panel") as string },
});
doc.write("derived", {
...doc.read("derived"),
panel: route.read("panel"),
dirty: doc.read("drafts").displayName.length > 0 || doc.read("drafts").timezone.length > 0,
summary: `tenant=${tenantId} panel=${route.read("panel")}`,
});
return doc;
}
Step 4: Render Fetch Tenant Settings (Cache Lane Defaults)
By default we bypass cache unless the route posture says otherwise.
import { useRenderFetch, useCacheBypass } from "react";
import { useGlobalRoute } from "react";
export function useTenantSettingsFetch(tenantId: string) {
const route = useGlobalRoute("settings", { posture: { cacheLane: "bypass" } });
const cache = useCacheBypass({ enabled: route.read("posture").cacheLane === "bypass" });
return useRenderFetch(
`tenant:${tenantId}:settings`,
async () => {
return { displayName: `Tenant ${tenantId}`, timezone: "UTC", plan: route.read("plan") };
},
{ cache }
);
}
Step 5: Panels as Component Routes (Motion Without URL Churn)
We keep the file route calm and let motion happen inside.
import { useGlobalRoute } from "react";
export function PanelTabs() {
const route = useGlobalRoute("settings", { panel: "profile" });
return (
<nav>
<button onClick={() => route.write("panel", "profile")}>Profile</button>
<button onClick={() => route.write("panel", "billing")}>Billing</button>
<button onClick={() => route.write("panel", "security")}>Security</button>
</nav>
);
}
Step 6: Mutations (No Invalidation by Default)
Write optimistic updates into the document + scope, then re-derive. Keep moving.
export function saveTenantProfile(doc: any, scope: any, next: { displayName: string; timezone: string }) {
doc.write("drafts", next);
doc.write("derived", { ...doc.read("derived"), dirty: false });
scope.write("defaults", { ...scope.read("defaults"), timezone: next.timezone });
}
Step 7: Testing (Evidence-first Snapshots)
Snapshot the shell with:
data-tenantdata-plandata-overrides
Then route-flow test tenant switching:
test.routeFlow("switch tenant updates scope", async (flow) => {
await flow.visit("/settings/t1");
await flow.expect("[data-tenant='t1']");
await flow.click("Switch to t2");
await flow.expect("[data-tenant='t2']");
});
Tenancy as a Scope Stack (Base + Tenant + Override)
Settings apps get unstable when tenancy is handled as "just another param" and every component re-derives policy. The standard posture in this guide is to treat tenancy as a scope stack:
- a base scope (app defaults),
- a tenant overlay (plan + policy),
- and an override overlay (temporary operator posture).
Keep open:
Scope Stack Implementation (createScope + useScope)
import { createScope, useScope } from "react";
const BaseScope = createScope({ theme: "warm", region: "us", policy: { allowExports: true } });
const TenantScope = createScope({ plan: "starter", policy: { allowSso: false, allowExports: false } });
const OverrideScope = createScope({ posture: "none" as "none" | "operator", policy: { allowExports: true } });
export function SettingsScopes({ children }: { children: React.ReactNode }) {
const base = useScope(BaseScope);
const tenant = useScope(TenantScope);
const override = useScope(OverrideScope);
return (
<section
data-theme={base.theme}
data-plan={tenant.plan}
data-posture={override.posture}
data-exports={String(override.policy.allowExports && tenant.policy.allowExports)}
>
{children}
</section>
);
}
Reference: Scope APIs.
Override Registry Vault (Operators Change Policy)
import { createStateVault } from "react";
export const settingsVault = createStateVault("settings", {
overridesByTenant: {} as Record<string, any>,
audit: [] as Array<{ at: number; tenant: string; action: string; note: string }>,
derived: { lastOverrideAt: 0, version: "v2" },
});
Apply Overrides During Render (Then Record Audit)
import { useRenderEffect } from "react";
import { useGlobalRoute } from "react";
import { useVaultMirror, useVaultWrite } from "react";
export function OverrideLane() {
const route = useGlobalRoute("settings", { tenantId: "t1", panel: "profile" });
const vault = useVaultMirror("settings");
const write = useVaultWrite("settings");
useRenderEffect(() => {
const tenantId = route.read("tenantId");
const override = vault.read("overridesByTenant")[tenantId] ?? { posture: "none" };
write("derived", { ...vault.read("derived"), lastOverrideAt: Date.now(), version: `v2:${Date.now()}` });
write("audit", [...vault.read("audit"), { at: Date.now(), tenant: tenantId, action: "override-read", note: JSON.stringify(override) }]);
});
return null;
}
Reference: useRenderEffect.
Policy-Based Redirects (Security Posture as a Route Step)
import { useRedirect } from "react";
import { useGlobalRoute } from "react";
export function SsoGate() {
const route = useGlobalRoute("settings", { plan: "starter", panel: "security" });
useRedirect("/settings/upgrade", { when: route.read("plan") === "starter" && route.read("panel") === "security" ? "missing-token" : undefined });
return null;
}
Reference: Security APIs.
Route-Flow Tests: Switch Tenant + Apply Override + Snapshot
import { runRouteFlow, createSnapshot } from "react";
createSnapshot("settings:tenant");
runRouteFlow("/settings/t1", {
steps: ["mount", "write:tenantId=t2", "write:panel=billing", "snapshot:settings-tenant-switch"],
});
Reference: Testing APIs.