QUESTPIE
GuidesMulti tenancy

The admin scope picker

Every export the scope system ships, the props each one takes, and the one wiring mistake that leaves the dropdown changing nothing.

View markdown

Six exports, all from @questpie/admin/client.

ExportWhat it is
ScopeProviderReact provider holding the selected scope id
useScopeReads and sets the scope. Throws outside the provider
useScopeSafeSame, but returns null outside the provider
useScopedFetchA fetch wrapper that sets the scope header
createScopedFetchThe same wrapper, for code outside React
ScopePickerThe dropdown

ScopeProvider

Holds the selection and persists it. Wrap it above AdminProvider or AdminLayoutProvider.

<ScopeProvider
	headerName="x-tenant-id"
	storageKey="admin-tenant"
	defaultScope={null}
>
	<AdminProvider {...props}>{children}</AdminProvider>
</ScopeProvider>
PropTypeDefaultWhat it does
headerNamestringrequiredThe header carrying the scope id. Match your resolver.
storageKeystringnonelocalStorage key. Omit and the choice is lost on reload.
defaultScopestring | nullnullUsed when nothing is stored.

Selecting a scope writes to localStorage. Clearing it removes the entry. All localStorage errors are swallowed, so private browsing degrades to a non-persistent picker rather than a crash.

Reading the scope

import { useScope } from "@questpie/admin/client";

function TenantBadge() {
	const { scopeId } = useScope();
	return <span>Active tenant: {scopeId ?? "all"}</span>;
}

useScope() returns { scopeId, setScope, clearScope, headerName, isLoading }. It throws outside a ScopeProvider. useScopeSafe() returns null instead, so use it to test whether scoping is switched on at all.

Sending the scope

The provider only holds a value. Something has to put it on the wire.

const scopedFetch = useScopedFetch();
const client = useMemo(
	() =>
		createClient<AppConfig>({
			baseURL: window.location.origin,
			basePath: "/api",
			fetch: scopedFetch,
		}),
	[scopedFetch],
);

The wrapper sets your headerName to scopeId on every request. It skips the header entirely when scopeId is null, so clearing the picker sends nothing.

Outside React, use createScopedFetch(headerName, () => currentScopeId). Same behaviour, no hook.

`AdminLayoutProvider` does not scope your client

It passes the client you gave it straight to AdminProvider. It never wraps it. Hand it a plain client and the picker changes scopeId while every response stays the same.

ScopePicker

The dropdown. Options come from one of three sources, in this order: static options, then a collection, then an async loadOptions. The first one you supply wins.

<ScopePicker
	collection="tenants"
	labelField="name"
	valueField="id"
	placeholder="Select tenant..."
	allowClear
	clearText="All tenants"
	compact
/>
PropTypeDefaultWhat it does
collectionstringnoneCollection to fetch options from.
labelFieldstring"name"Field used as each option's label.
valueFieldstring"id"Field used as each option's value, the scope id.
optionsScopeOption[]noneStatic options. Beats collection and loadOptions.
loadOptions() => Promise<ScopeOption[]>noneAsync loader.
placeholderstring"Select..."Shown when nothing is selected.
labelstringnoneText above the picker. Hidden in compact.
allowClearbooleanfalseAdds an option that calls setScope(null).
clearTextstring"All"Text for that option.
compactbooleanfalseSmaller, no label. Fits a sidebar slot.
classNamestringnoneExtra classes on the wrapper.

ScopeOption is { value, label, description?, icon? }.

The collection source calls find({ limit: 100, columns: { [valueField]: true, [labelField]: true } }) and maps each row to { value, label }. Results are cached for a minute. A tenant list longer than 100 needs loadOptions.

The options collection must survive its own scope

ScopePicker reads through the same scoped client. So the tenant list itself must stay readable across scopes. Scope the content collections that hang off it, not the directory collection.

Placing it in the sidebar

sidebarProps.afterBrand is the slot under the brand.

sidebarProps={{ afterBrand: <ScopePicker collection="tenants" allowClear compact /> }}

The slot renders only when the sidebar is expanded. Collapse the sidebar and the picker is hidden, though the selection is untouched.

ScopeContextValue, ScopeOption, ScopeProviderProps and ScopePickerProps are exported as types from the same entry point.

On this page