Multi-tenancy
Derive the active tenant from a request header, filter each collection with an access rule, give every tenant its own settings row, and put a tenant switcher in the admin sidebar.
Scope is request state, not configuration. Nothing in a collection marks it as owned by a tenant. You derive the tenant once per request, then read that value wherever isolation has to happen. This page walks the whole loop. At the end, picking a tenant in the sidebar changes what the API returns.
Derive the tenant from the request
appConfig({ context }) is the seam. It runs once per HTTP request.
import { appConfig } from "questpie/app";
export default appConfig({
context: async ({ request }) => {
const tenantId = request.headers.get("x-tenant-id");
return { tenantId: tenantId || null };
},
});The returned object is merged flat into the request context. It reaches access
rules, hooks, route handlers and getContext(). That is all it does. The
resolver derives the scope. It never enforces it.
Filter a collection by it
Collections have no scoped option. Isolation is an access rule you write.
import { collection } from "#questpie/factories";
export const posts = collection("posts")
.fields(({ f }) => ({
tenant: f.relation("tenants").label("Tenant").required(),
title: f.text(255).label("Title").required(),
body: f.textarea().label("Body"),
}))
.access({
read: ({ tenantId }) => ({ tenant: tenantId }),
update: ({ tenantId }) => ({ tenant: tenantId }),
delete: ({ tenantId }) => ({ tenant: tenantId }),
})
.hooks({
beforeValidate: ({ data, operation, tenantId }) => {
if (operation === "create" && tenantId) data.tenant = tenantId;
},
})
.title(({ f }) => f.title);The scope field
A plain relation to a tenants collection you declare yourself. It is the column
the filter keys on. Use f.text() when the tenant id comes from outside the app.
The access rules
They return an object instead of true. On read the object is ANDed into the
query, so other tenants' rows never load. On update and delete it is matched
against the row that was already loaded. A row from another tenant throws a 403.
The beforeValidate stamp
It sets the field on create. Put it there, not in beforeChange. The insert
schema parses between the two. tenant is required, so a create without it is
rejected before beforeChange ever runs.
A missing tenant filters to `tenant IS NULL`
{ tenant: null } is not "no filter". It matches rows whose FK is null.
Reject a missing scope in the resolver rather than letting null reach an
access rule.
Give each tenant its own settings
Globals are the one place scope is a real option. A singleton per tenant needs storage support, so the framework builds it for you.
import { global } from "#questpie/factories";
export default global("siteSettings")
.fields(({ f }) => ({
siteName: f.text().label("Site name"),
primaryColor: f.text().label("Primary colour").default("#0ea5e9"),
}))
.options({
versioning: true,
scoped: (ctx) => (ctx as typeof ctx & { tenantId: string | null }).tenantId,
});The cast is not style. scoped is typed (ctx: BaseRequestContext) => ...,
and that interface carries no index signature and no augmentation seam. So a key
you added to the context is invisible to it, and the cast is how you name it
back. RequestContext elsewhere in the framework does allow extra keys, and
Questpie.AppContext is the interface you augment, but scoped reaches
neither.
scoped adds a scope_id text column and a unique index named
siteSettings_scope_idx. get() and update() then filter by the resolved id.
You never create a per-tenant row by hand. The first get() under a new scope
inserts that row and returns it. A null scope resolves the single row whose
scope_id is null.
The cast is not decoration. scoped is typed against the base request context,
which knows nothing about your resolver. The value is there at runtime.
Let admins switch tenants
Three pieces come from @questpie/admin/client. ScopeProvider holds the
selection. useScopedFetch sends it. ScopePicker is the dropdown.
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 { AppConfig } from "@/questpie/server/.generated";
const baseURL = typeof window === "undefined" ? "" : window.location.origin;
export 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 }) {
// Sets x-tenant-id on every request this client makes.
const scopedFetch = useScopedFetch();
const client = useMemo(
() =>
createClient<AppConfig>({
baseURL,
basePath: "/api",
fetch: scopedFetch,
}),
[scopedFetch],
);
return (
<AdminLayoutProvider
admin={admin}
client={client}
// AdminLink is the router adapter the starter already wrote here.
LinkComponent={AdminLink}
sidebarProps={{
afterBrand: <ScopePicker collection="tenants" allowClear compact />,
}}
>
{children}
</AdminLayoutProvider>
);
}Wrap ScopeProvider above the admin provider and build the client inside it.
AdminLayoutProvider hands your client straight to AdminProvider and never
wraps it. Pass a plain client and the picker changes scopeId without changing
a single response. The header only goes out when a tenant is selected.
What you have now
questpie generate # picks up the collection, the global and the resolver
questpie push # creates the tables in your dev databasePick a tenant in the sidebar. The client sends x-tenant-id. The resolver
returns { tenantId }. The posts list drops to that tenant's rows, and
siteSettings swaps to that tenant's row. Switch again and both follow.
Deriving is not enforcing
A collection with no scope rule stays visible to every tenant. Add the rule to every collection holding tenant data, including ones you only reach through a relation.
Picking a shape
| You want | Use | Storage |
|---|---|---|
| Per-tenant rows | scope field, access rules, a beforeValidate stamp | one table, filtered per request |
| Per-tenant settings | global().options({ scoped }) | one row per scope |
| Per-tenant membership or roles | a join collection, itself scoped | one table |
| Shared identity | the built-in user collection, left alone | one table, no scope |
Where each topic lives
| Topic | Page |
|---|---|
| The resolver in full, reserved keys, jobs and scripts | The scope resolver |
| Every prop on the provider, the hooks and the picker | The admin scope picker |
| Relations, the shared user table, system mode | Where isolation leaks |
Next
Access control is the rule model this page
leans on, including everything else a returned where can do.