Multi-tenancy
Serve many tenants, organizations, properties, cities, from one app by deriving the active scope from the request, isolating data with access rules and scoped globals, and letting admins switch tenants from the sidebar.
Multi-tenancy in QUESTPIE is one app serving many isolated tenants, call them organizations, workspaces, properties, or cities. There is no special "tenant mode": you assemble it from primitives you already know. The client sends the active scope as a request header, an appConfig({ context }) resolver turns that header into a typed value on every request context, and your access rules and scoped globals read that value to isolate data. Pick the tenant from a sidebar dropdown, and every API call carries it.
This page shows the full loop end to end, server resolver, scoped collections, scoped globals, the admin scope picker, and the two isolation footguns that bite hardest: relations that reach across scopes, and the shared user table that is not tenant-scoped.
What it does
- Derive scope from the request. An
appConfig({ context })resolver reads a header (x-tenant-id,x-selected-property, …) once per request and returns a typed value ({ tenantId }) that travels into every access rule, hook, route handler, andgetContext(). - Isolate collection data with access rules. Add a scope-FK field, then a
read/updaterule that returns awherefilter keyed on the resolved scope, rows outside the active tenant simply never appear. - Per-tenant singletons. Mark a global
.options({ scoped })and each tenant gets its own row automatically (scope_idcolumn + unique index), with no extra tables. - Switch tenants in the admin.
ScopeProvider+ScopePickergive admins a sidebar dropdown;useScopedFetchinjects the chosen scope header into every request from the typed client. - One codebase, every surface. The same scope flows through CRUD, REST, the typed client, and the admin, you wire it once at the seams, not per query.
How the pieces fit
Admin browser Your server
┌───────────────────────┐ ┌────────────────────────────────────┐
│ ScopeProvider │ header │ appConfig({ context }) resolver │
│ └ ScopePicker ───────────────────▶ │ reads request.headers.get(...) │
│ └ useScopedFetch x-tenant-id │ returns { tenantId } │
│ (typed client) │ │ │ merged flat into ctx │
└───────────────────────┘ │ ▼ │
│ access: ({ tenantId }) => ({ ... }) │ ← scoped collections
│ global .options({ scoped }) │ ← scoped singletons
└────────────────────────────────────┘The scope is request state, not configuration. Nothing about a collection is "owned" by a tenant in its definition, isolation is the access rule you write, evaluated per request against the resolved scope.
Quick start
A complete two-tenant setup needs three files: the resolver, a scoped collection, and the admin wiring. The runnable examples/city-portal shows the same backbone, a per-request resolver, a ScopePicker in the sidebar, a scoped siteSettings global, and a membership collection. The access-rule pattern for collections below is the recommended way to isolate per-tenant rows; the example carries the scope-FK field but leaves the access enforcement to you, so treat the collection snippet as the QUESTPIE pattern rather than a line-for-line copy of the example.
1. Resolve the scope from the request in config/app.ts. The resolver runs once per HTTP request; its return value is merged flat into the request context.
import { appConfig } from "questpie/app";
export default appConfig({
locale: { locales: [{ code: "en" }], defaultLocale: "en" },
// Runs once per request. The returned object travels into every access
// rule, hook, route handler, and getContext(), keyed on the header the
// client sends.
context: async ({ request }) => {
const tenantId = request.headers.get("x-tenant-id");
return { tenantId: tenantId || null };
},
});2. Scope a collection with a scope-FK field plus an access rule that returns a where filter. Reads outside the active tenant return nothing; the beforeChange hook stamps the FK so new rows can't escape their scope.
import { collection } from "#questpie/factories";
// ctx is the resolved request context, `tenantId` came from the resolver above.
type ScopeCtx = { tenantId: string | null };
export const posts = collection("posts")
.fields(({ f }) => ({
tenant: f.relation("tenants").label("Tenant").required(),
title: f.text().label("Title").required(),
body: f.richText().label("Body"),
}))
.access({
// Row-level filter: only rows whose tenant FK matches the active scope.
read: ({ tenantId }: ScopeCtx) => ({ tenant: tenantId }),
update: ({ tenantId }: ScopeCtx) => ({ tenant: tenantId }),
create: ({ session }) => Boolean(session),
})
.hooks({
beforeChange: ({ data, operation, ...ctx }) => {
// Stamp the scope FK on create so a row can never be born outside its tenant.
const { tenantId } = ctx as unknown as ScopeCtx;
if (operation === "create" && tenantId) data.tenant = tenantId;
},
})
.title(({ f }) => f.title);3. Wire the admin so an editor can switch tenants. ScopeProvider holds the selection, useScopedFetch builds a client that injects the header, and ScopePicker is the dropdown. Wrap ScopeProvider above AdminProvider (or AdminLayoutProvider), and build the client inside it so the picker's value reaches every request.
import {
AdminLayoutProvider,
ScopePicker,
ScopeProvider,
useScopedFetch,
} from "@questpie/admin/client";
import { createClient } from "questpie/client";
import { useMemo } from "react";
import admin from "@/questpie/admin/.generated/client";
import type { app } from "@/questpie/server";
function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<ScopeProvider headerName="x-tenant-id" storageKey="admin-tenant">
<ScopedAdmin>{children}</ScopedAdmin>
</ScopeProvider>
);
}
function ScopedAdmin({ children }: { children: React.ReactNode }) {
// useScopedFetch reads the active scope from ScopeProvider and sets the
// x-tenant-id header on every request. A plain client would NOT send it, // the picker would change scopeId without changing what the API returns.
const scopedFetch = useScopedFetch();
const client = useMemo(
() => createClient<typeof app>({ baseURL: "/api", fetch: scopedFetch }),
[scopedFetch],
);
return (
<AdminLayoutProvider
admin={admin}
client={client}
LinkComponent={AdminLink}
sidebarProps={{
// The dropdown lives in the sidebar; options come from a collection.
afterBrand: <ScopePicker collection="tenants" labelField="name" allowClear />,
}}
>
{children}
</AdminLayoutProvider>
);
}Then regenerate and create the tables:
questpie generate # picks up the new collection + resolver
questpie push # creates the posts + tenants tables in devThat is the whole loop. Pick a tenant in the sidebar → the scoped client sends x-tenant-id → the resolver returns { tenantId } → the access rule filters posts to that tenant. The scoped fetch is the load-bearing seam: skip useScopedFetch and the picker still changes scopeId, but no header leaves the browser, so the API keeps returning everything.
The resolver only *derives* scope, it does not enforce it
appConfig({ context }) returns a value; it does not filter anything by itself. Isolation happens only where you read that value, in access rules (collections) or the scoped resolver (globals). A collection with no scope rule is fully visible across every tenant. Derive once, enforce per collection.
The scope resolver, appConfig({ context })
context is one key on the appConfig() object in config/app.ts. It is the single seam for turning request data into per-request state.
export type ContextResolver<T extends Record<string, any>> = (
params: ContextResolverParams & Questpie.ContextResolverContext,
) => Promise<T> | T;
interface ContextResolverParams {
request: Request; // the incoming HTTP request
session: /* your typed session */ | null | undefined;
db: /* your db client */;
}What you get in the resolver. Beyond request, session, and db, the framework passes the full system-mode service surface, collections, globals, kv, queue, logger, t, and your services, typed via the codegen-emitted Questpie.ContextResolverContext augmentation. So you can validate membership or load tenant config from inside the resolver:
export default appConfig({
context: async ({ request, session, collections }) => {
const tenantId = request.headers.get("x-tenant-id");
// Collections called here run in SYSTEM mode, the resolver is trusted
// derivation, so it is not itself subject to scope rules (which is what
// lets it establish the scope in the first place).
if (tenantId && session?.user) {
const member = await collections.tenantMembers.findOne({
where: { tenant: tenantId, user: session.user.id },
});
if (!member) throw new Error("No access to this tenant");
}
return { tenantId };
},
});Where the return value lands. The resolved object is merged flat into the request context and also carried as an internal "~contextExtensions" bundle, so it reaches:
- access rules,
read/create/update/delete/transition/serve(collections) and global access rules, - hooks, every collection/global hook context spreads it,
- route handlers,
ctx.tenantIdinroutes/, getContext(), anywhere inside the request scope (getContext<typeof app>().tenantId).
`accessMode` is `system` off-request, scope is not auto-applied in jobs and scripts
The resolver runs only when there is a request. CRUD calls with no request (jobs, seeds, scripts, the resolver itself) default to accessMode: "system", which bypasses access rules entirely, so your scope where never fires there. Inside a job you must pass the tenant explicitly (find({ where: { tenant: id } })) or set { accessMode: "user", session } and inject the context yourself. HTTP requests default to accessMode: "user", where the rules apply.
Reserved keys
The framework tracks a set of reserved context keys, the ones it sets itself:
session, db, locale, defaultLocale, localeFallback, accessMode, stage,
request, requestId, traceId, data, input, original, operation, params,
app, collections, globals, "~contextExtensions"Returning one of these from your resolver does not throw, in development it logs a one-time warning, and in production it is silent. So watch your console: name your scope key something domain-specific (tenantId, organizationId, propertyId, cityId) and you'll never hit it.
The protection itself is partial. Your resolver's result is merged flat into the context, then five base keys, session, locale, defaultLocale, accessMode, db, are re-set on top, so those genuinely can't be shadowed. The other reserved keys (request, requestId, data, operation, collections, globals, …) are not re-set, so a resolver that returns one of those names would overwrite it in the flat context. Treat the warning as a real signal, not a guardrail.
The resolver must return an object
ValidateContextResolver rejects a resolver that resolves to only a primitive (async () => "x") or only null, extensions are an object bundle. The common session ? { tenantId } : null shape passes because it has an object arm; returning { tenantId: id || null } is the clean form.
Scoping collections
Collections have no scoped option, collection isolation is access rules, and that is deliberate: it composes with everything else access already does (row filtering, field rules, serve). The pattern is three parts:
- A scope-FK field,
f.relation("tenants")(or a plainf.text()if the tenant id is external). This is the column the filter keys on. - An access
wherefilter,read/update/deleterules that return{ tenant: tenantId }. A rule that returns awhereobject grants filtered access: rows are narrowed, not denied. (See Access control for the fullwhere-returning model.) - A
beforeChangestamp, set the FK from the resolved scope oncreateso a row cannot be written into the wrong tenant.
collection("invoices")
.fields(({ f }) => ({
tenant: f.relation("tenants").required(),
amount: f.number().required(),
}))
.access({
read: ({ tenantId }) => ({ tenant: tenantId }), // narrows the list
update: ({ tenantId }) => ({ tenant: tenantId }), // can't update others' rows
delete: ({ tenantId, session }) =>
session?.user.role === "admin" ? { tenant: tenantId } : false,
})
.hooks({
beforeChange: ({ data, operation, ...ctx }) => {
if (operation === "create") data.tenant = (ctx as any).tenantId;
},
});Rules apply only in user mode, and `null` scope means `tenant IS NULL`
Two things to internalize. First, the filter runs only when access is enforced (accessMode: "user", the HTTP default; or explicitly passed). A backend find() with the default system mode sees all tenants, correct for trusted code, dangerous if you forget which mode you're in. Second, { tenant: null } filters to rows whose FK is null (unassigned rows), which is rarely what you want, guard for a missing scope (reject, or scope to nothing) rather than letting null leak global rows.
Scoping globals, .options({ scoped })
Globals are the one place scope is a first-class option, because a singleton-per-tenant needs storage support: a scope_id column and a per-scope unique index. Set scoped to a resolver that returns the scope id from context, and QUESTPIE gives each scope its own row automatically.
import { global } from "#questpie/factories";
export default global("siteSettings")
.fields(({ f }) => ({
siteName: f.text().label("Site name"),
primaryColor: f.text().label("Primary color").default("#0ea5e9"),
}))
.options({
timestamps: true,
versioning: true,
// Each tenant gets its own siteSettings row. ctx is the request context,
// so `tenantId` came from the appConfig({ context }) resolver.
scoped: (ctx) => (ctx as typeof ctx & { tenantId: string | null }).tenantId,
});GlobalScopeResolver is (ctx: BaseRequestContext) => string | null | undefined. Enabling scoped:
- adds a
scope_idtext column to the global's main table, - adds a unique index
<name>_scope_idxonscope_id, - makes
get()/update()filter and upsert by the resolved scope id,get()returns this tenant's row (auto-created on first access, read or write, with itsscope_id), and anullscope resolves the one row wherescope_id IS NULL(the unscoped/global instance).
Scoped globals self-provision per tenant
You never create the per-tenant row manually. The first time you touch siteSettings while tenantId resolves to "t_42", a get() is enough, you don't have to update() first, the read path inserts a row with scope_id = "t_42" inside a locked transaction and returns it. Switching scope switches which row you read and write, same global name, isolated rows.
The admin scope picker
The client primitives live in @questpie/admin/client. They are three coordinated pieces.
ScopeProvider
Holds the selected scope and persists it. Wrap it above AdminProvider / AdminLayoutProvider so the scoped fetch reaches the client.
<ScopeProvider headerName="x-tenant-id" storageKey="admin-tenant" defaultScope={null}>
<AdminProvider {...props}>{children}</AdminProvider>
</ScopeProvider>ScopeProviderProps:
| Prop | Type | Default | Purpose |
|---|---|---|---|
headerName | string | none (required) | HTTP header carrying the scope id (must match what the resolver reads). |
storageKey | string | none | localStorage key to persist the selection across reloads. Omit to not persist. |
defaultScope | string | null | null | Scope id used when nothing is stored. |
Reading and setting scope
import { useScope, useScopeSafe } from "@questpie/admin/client";
function TenantBadge() {
const { scopeId, setScope, clearScope } = useScope(); // throws outside provider
return <span>Active tenant: {scopeId ?? "all"}</span>;
}useScope() returns ScopeContextValue { scopeId, setScope, clearScope, headerName, isLoading? } and throws outside a ScopeProvider; useScopeSafe() returns null instead of throwing (use it to probe whether scoping is enabled).
useScopedFetch / createScopedFetch
The provider only holds the value, you must give the typed client a fetch that sends it. useScopedFetch() returns a fetch wrapper that sets headerName: scopeId on every request (only when scopeId is truthy). Build the client with it:
import { createClient } from "questpie/client";
import { useScopedFetch } from "@questpie/admin/client";
import { useMemo } from "react";
function AdminWithScopedClient() {
const scopedFetch = useScopedFetch();
const client = useMemo(
() => createClient<typeof app>({ baseURL: "/api", fetch: scopedFetch }),
[scopedFetch],
);
return <AdminProvider client={client} {...rest} />;
}For client construction outside React, use createScopedFetch(headerName, () => currentScopeId), same behavior, no hook.
`AdminLayoutProvider` does *not* auto-scope your client
AdminLayoutProvider forwards the client you pass straight through to AdminProvider, it does not wrap it with a scope-aware fetch. So when the active scope changes, the admin only re-issues queries with the new header if the client you handed it already uses a scoped fetch. Build that client with useScopedFetch (as above), or the dropdown will change scopeId without changing what the API returns.
ScopePicker
The dropdown. It sources options three ways, in priority order: static options → a collection (calls client.collections[collection].find({ limit: 100, columns: { [valueField]: true, [labelField]: true } }), projecting just those two fields and mapping each row to { value, label }) → an async loadOptions. Typically placed in the sidebar via sidebarProps.
<ScopePicker
collection="tenants" // fetches { value: id, label: name } from this collection
labelField="name" // default "name"
valueField="id" // default "id"
placeholder="Select tenant..."
allowClear // adds an "All" option that calls setScope(null)
clearText="All tenants" // default "All"
compact // smaller, no label, fits a sidebar slot
/>ScopePickerProps:
| Prop | Type | Default | Purpose |
|---|---|---|---|
collection | string | none | Collection to fetch options from (find limit 100). |
labelField | string | "name" | Field used as each option's label. |
valueField | string | "id" | Field used as each option's value (the scope id). |
options | ScopeOption[] | none | Static options; wins over collection/loadOptions. |
loadOptions | () => Promise<ScopeOption[]> | none | Async options loader. |
placeholder | string | "Select..." | Shown when nothing is selected. |
label | string | none | Label above the picker (hidden in compact). |
allowClear | boolean | false | Adds an "All" option mapping to setScope(null). |
clearText | string | "All" | Text for the clear/all option. |
compact | boolean | false | Compact rendering for a sidebar slot. |
ScopeOption is { value, label, description?, icon? }.
The options collection must be visible at the current scope
ScopePicker collection="tenants" reads the tenants collection through the same scoped client, so the tenant list itself must not be scoped away to nothing, or the picker comes up empty. Keep the directory collection (tenants/organizations) readable across scopes, and isolate the content collections that hang off it.
Gotcha: relations reach across scopes
This is the isolation footgun that bites hardest. Scope lives in each collection's own access rules. When you hydrate a relation with with:, QUESTPIE runs the related collection's own CRUD read, propagating the parent's accessMode and session, so the related collection's access rules (including its scope filter) do apply in user mode. The consequence:
- A relation to a scoped collection is safe, its own
readrule narrows the hydrated rows to the active tenant. - A relation to an unscoped collection leaks every tenant's rows into the hydrated result, because that collection has no filter to apply.
// posts is scoped (read: ({ tenantId }) => ({ tenant: tenantId }))
// comments is NOT scoped → this hydrates comments from ALL tenants
await app.collections.posts.find(
{ with: { comments: true } },
{ accessMode: "user", session },
);The fix is uniform: scope every collection that holds tenant data, including the ones you only ever reach through a relation. There is no "scope a relation" shortcut, isolation is per-collection by design.
Only upload relations inherit the parent's read decision
The one exception is f.upload() relations: they carry an internal inheritAccess flag (defaulting to true only for uploads), so the file relation populates from the parent row's read decision instead of re-running collection-level access, field-level read rules still apply. Every non-upload relation defaults to inheritAccess: undefined and re-runs the target's own access. That symbol is framework-internal and cannot be injected over HTTP (JSON can't carry symbols), so a request can never opt a relation out of its scope rule.
Gotcha: the user / auth collection is not tenant-scoped
The user collection ships from the starter module (the admin module .merge()s it to add the admin UI), and it has no scoped option, it is a single shared identity store across every tenant, by design. A user is one account that may belong to many tenants; you don't fork the user row per tenant.
Model the relationship between users and tenants as its own collection, a membership/join collection, and scope that:
collection("tenantMembers")
.fields(({ f }) => ({
user: f.relation("user").required(), // the SHARED user collection
tenant: f.relation("tenants").required(),
role: f.select([
{ value: "admin", label: "Admin" },
{ value: "editor", label: "Editor" },
{ value: "viewer", label: "Viewer" },
]).default("editor").required(),
}))
.indexes(({ table }) => [
uniqueIndex("tenant_members_unique").on(table.user, table.tenant),
]);This is exactly what examples/city-portal does with its cityMembers collection (user ↔ city + role, with a unique index on the pair). From there, the membership-validating resolver above is the recommended next step: check that the session user actually belongs to the requested tenant before trusting the header. (The example derives cityId from the header but leaves that membership check to you.)
Don't try to scope `user`, scope membership instead
Adding a scope rule to user breaks login, the admin people-list, and every f.relation("user") picker, because authentication needs the global user lookup. Keep user shared; put the per-tenant boundary on a membership collection and enforce it in the resolver and in each content collection's access rules.
Common shape, picking a scope strategy
| You want | Use | Storage |
|---|---|---|
| Per-tenant rows in a collection | scope-FK field + access where filter + beforeChange stamp | a shared table, filtered per request |
| Per-tenant singleton (settings, theme) | global().options({ scoped }) | one row per scope (scope_id + unique index) |
| Per-tenant membership / roles | a dedicated join collection (tenantMembers), itself scoped | a shared table |
| Shared identity across tenants | the built-in user collection (leave unscoped) | one table, global |
| Hard isolation (separate Postgres schema) | .options({ schema: "tenant_x" }) per tenant set | distinct schemas (static tenant set only) |
The first two cover almost every app. Reach for schema only when you need physical separation and your tenant set is known at build time, it is not a per-request switch.
TypeScript
Read the resolved scope anywhere in the request scope with the typed getContext:
import { getContext } from "questpie";
import type { app } from "@/questpie/server"; // your built app type
function currentTenant() {
// getContext infers your resolver's return through the app type.
return getContext<typeof app>().tenantId; // string | null
}getContext() throws if called outside a request scope (use tryGetContext() for a safe probe). The scope value is also present directly on every access-rule and hook context (({ tenantId }) => …). The scope client/types, ScopeContextValue, ScopeOption, ScopeProviderProps, ScopePickerProps, are exported from @questpie/admin/client.
Related
- Access control, the
where-returning rule model that does collection-level scoping, plus field-level rules and theserve/introspectchains. - Configuration,
appConfig()in full (locale,access,hooks,context) and theconfig/*.tsconvention. - Globals, the singleton model that
.options({ scoped })extends. - Collections, fields, access, hooks, and the
schemaoption used for hard isolation. - Hooks, where
beforeChangestamps the scope FK, and the transaction lifecycle. - Relations,
with:hydration, whose access behavior drives the cross-scope leakage gotcha above. - Runnable example:
examples/city-portal, multi-city portal with ax-selected-cityresolver, aScopePickerin the sidebar, a scopedsiteSettingsglobal, and acityMembersjoin collection. Its content collections carry acityrelation FK; the public site isolates by the$citySlugroute param rather than access rules, so the access-rule pattern on this page is the recommended extension of it.
Codegen
Codegen is the compiler that turns your file-convention source, collections, globals, routes, config, into one typed .generated/ app. You write declarations, run `questpie generate`, and every layer (CRUD, admin, REST, OpenAPI, typed client) wires itself up in sync.
Overview
External-facing modules that expose your QUESTPIE app to tools and API consumers through MCP and OpenAPI.