Guide 22

2023-01-22Last updated 2026-02-16

Build a Multi-tenant SaaS Settings App

Mara KingGuide Systems EngineerRoute-first application assembly

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.

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: string
  • panel: "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 to t2data-tenant changes → 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-tenant
  • data-plan
  • data-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.