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.
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.
Dashboard
Today's takings — Acme Metals
Dashboard
Today's takings — Mukamba Mills
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
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.
switchTenant writes all three. URL is the share-safe truth.
Switch to the Code tab to see the snippet.
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.
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.
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.
Final composition
The whole TenantSwitcher + provider, assembled. Context, persistence, trigger, menu, hotkeys, banner.
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-labelincludes 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 hasrole="listbox"and itemsrole="option". - Current tenant is announced. The active row has
aria-current="true"and the trailing check hasaria-label="current", so screen-reader users know which one's live. - Hotkeys don't fight inputs.
Cmd+1..9 is captured atwindow, but the handler bails early ife.targetis a text input — so typing "1" in search doesn't jump. - Switch is announced. The post-switch banner uses
Toastwithrole="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.