Home/ Cookbook/ Shells/ Multi-tenant switcher

Build a multi-tenant switcher operators can't fire the wrong gun with

By the end of this recipe you'll have a top-bar tenant switcher with avatar + business name + chevron, a searchable menu, persisted last-active tenant, Cmd+1..9 shortcuts, and a 5-second confirmation banner that says "you're now writing to Acme Metals" everywhere on screen.

Shells 4 primitives · 1 block · 0 patterns ~30 min React · @corelithzw/react

Overview

In the top-left of the shell: avatar + business name + chevron. Click → searchable menu of every business the user belongs to. Click one → the whole app re-renders against that tenant, and a banner across all open tabs confirms the switch for 5 seconds.

SMB operators are sole traders. They run a hardware store, a side hustle hauling scrap, and the books for their cousin's panel-beating yard. One Huchu login covers all three. The switcher is what stops a stock-take being recorded against the wrong business — which, in the offline-first world we ship to, can take days to untangle.

Never let a user accidentally write to the wrong tenant. The switch confirms with a persistent banner across all top bars for 5 seconds — long enough for the operator's brain to catch up with their muscle memory before they tap "Save".

The active tenant lives in three places: a TenantContext at the React root, localStorage for last-active across reloads, and the URL path so links survive sharing. All three stay in sync via one switchTenant(id) action. Cmd+1..9 maps to the first nine entries in the user's tenant list — same as Slack workspaces, same as Chrome tabs, same muscle memory.

Skip this recipe if the user only ever belongs to one tenant. Don't render a switcher with a single item — it's noise, and the menu only appears when the user actually has a choice to make.

What you'll build

Three states: closed trigger, open menu with search, and the post-switch confirmation banner.

Huchu
AAcme Metals

Dashboard

Today's takings — Acme Metals

01 Top-bar trigger — current tenant
Switch business
⌕ Search…
AAcme Metals⌘1
MMukamba Mills⌘2
SSibanda Scrap⌘3
+ Create new business
02 Menu — search, shortcuts, create
Huchu
MMukamba Mills
Switched to Mukamba Mills. 4s

Dashboard

Today's takings — Mukamba Mills

03 5-second confirmation banner

Required pieces

Everything this recipe pulls from @corelithzw/react.

@corelithzw/react exports used here: Menu, Combobox, Avatar, Button, Stack, RowCard, Toast, useToast. A small TenantProvider + useTenant ship next to the switcher.

Step-by-step build

01

One context, three storage layers

The active tenant has to survive three things: a re-render (Context), a hard reload (localStorage), and a shared link (URL path). One switchTenant writes all three; everything else reads from Context. If two browser tabs disagree, the URL wins on the next click.

Code-only step

switchTenant writes all three. URL is the share-safe truth.

Switch to the Code tab to see the snippet.

Step 1 · Context + persistence@corelithzw/react
02

Render the top-bar trigger from the active tenant

The trigger is a single Button with an avatar, the business name and a chevron. The chevron is a visual cue, not a separate control — the whole pill opens the menu. If the user only has one tenant, render the avatar + name as plain text and skip the menu entirely.

A
Acme Metals
Step 2 · Trigger
03

The menu is a Combobox, not a Select

If the operator only has three businesses, a Menu would do — but you don't know that. By the time they're managing payroll for ten side hustles, search is the only fast input. Combobox ships search-on-type for free; Menu doesn't.

⌕ Search businesses…
A
Acme Metals ⌘1
M
Mukamba Mills ⌘2
Step 3 · Combobox menu
04

Wire Cmd+1..9 and the post-switch banner

Power users live on keyboard shortcuts. Cmd+digit jumps to that-indexed tenant — same as switching browser tabs. After every switch, a global Toast with variant="banner" stays on screen for 5 seconds, so any tab the user has open says "you're now writing to X". Five seconds is the magic number — long enough to read, short enough to not nag.

Switched to Mukamba Mills · Any save from now lands here. 4s
Step 4 · Hotkeys + banner

Final composition

The whole TenantSwitcher + provider, assembled. Context, persistence, trigger, menu, hotkeys, banner.

M
Mukamba Mills
Switched to Mukamba Mills. Any save from now lands here.
TenantSwitcher.tsx@corelithzw/react

Variations

Three forks of this recipe.

Owner / impersonation mode

Admins switching into a tenant they don't own. Add a banner that stays for the whole session — not just 5 seconds.

// Toast becomes a sticky banner
toast.show({
  variant: 'banner',
  tone: 'warning',
  title: `Viewing ${t.name} as admin`,
  duration: Infinity, // stays
  action: { label: 'Exit', onClick: exitImpersonation },
});

Subdomain-per-tenant

If each tenant lives at acme.huchu.app, the switch does a full location.assign — no Context rewire needed.

const switchTenant = (id: string) => {
  const host = window.location.host
    .replace(/^[^.]+\./, '');
  window.location.assign(
    `https://${id}.${host}`,
  );
};

Recent tenants pin

Operators with 30+ tenants need a "recent 5" pinned above the search. Track switches in localStorage, show the top 5 above the list.

// On switch
const recents = JSON.parse(
  localStorage.getItem('huchu:recents') ?? '[]');
const next = [id, ...recents.filter(
  (x: string) => x !== id)].slice(0, 5);
localStorage.setItem('huchu:recents',
  JSON.stringify(next));

Accessibility

What this recipe takes care of for you.

  • Trigger reads as a state. aria-label includes the current tenant ("Current business: Acme Metals. Click to switch") so screen readers don't lose the context behind the avatar.
  • Menu state is exposed. aria-haspopup="menu" + aria-expanded={open} on the trigger; the combobox listbox has role="listbox" and items role="option".
  • Current tenant is announced. The active row has aria-current="true" and the trailing check has aria-label="current", so screen-reader users know which one's live.
  • Hotkeys don't fight inputs. Cmd+1..9 is captured at window, but the handler bails early if e.target is a text input — so typing "1" in search doesn't jump.
  • Switch is announced. The post-switch banner uses Toast with role="status" + aria-live="polite" so the change is read out, not silent.
  • Single-tenant skips the menu. Saves a redundant tab stop and stops screen readers reading "menu, collapsed" when there's nothing to choose.
  • Esc closes the menu. Standard Combobox behaviour, restored focus on the trigger.