Multi-Tenancy
Scope-based multi-tenant architecture — isolate data per workspace, organization, or any entity using HTTP headers and typed context.
QUESTPIE supports multi-tenant applications through a scope-based architecture. A "scope" can represent anything: organizations, workspaces, properties, cities, brands — any entity that partitions data.
The pattern: HTTP header carries a scope ID → server extracts it into typed context → access rules filter data.
Context Resolver
The context.ts file convention extracts custom properties from each incoming request. Place it in src/questpie/server/context.ts and codegen discovers it automatically.
import { appConfig } from "questpie";
export default appConfig({
context: async ({ request, session, db }) => {
const workspaceId = request.headers.get("x-selected-workspace");
return { workspaceId: workspaceId || null };
},
});The resolver receives:
| Parameter | Type | Description |
|---|---|---|
request | Request | Incoming HTTP request (Web API) |
session | { user, session } | null | Resolved auth session |
db | Database | Database client for validation queries |
The returned object merges into the request context and is available in every handler, hook, and access rule.
Data Isolation
Use the typed context in access rules to enforce tenant isolation:
import { collection } from "#questpie/factories";
export default collection("projects")
.fields(({ f }) => ({
title: f.text().label("Title").required(),
workspace: f.relation("workspaces").required(),
}))
.access({
read: ({ ctx }) => {
if (!ctx.workspaceId) return false;
return { workspace: { equals: ctx.workspaceId } };
},
create: ({ ctx }) => !!ctx.workspaceId,
update: ({ ctx }) => {
if (!ctx.workspaceId) return false;
return { workspace: { equals: ctx.workspaceId } };
},
delete: ({ ctx }) => {
if (!ctx.workspaceId) return false;
return { workspace: { equals: ctx.workspaceId } };
},
})
.hooks({
beforeCreate: async ({ data, ctx }) => {
if (ctx.workspaceId) {
data.workspace = ctx.workspaceId;
}
},
});Access rules can return:
true— allow all recordsfalse— deny all records{ field: { equals: value } }— where-clause filter (row-level security)
Admin Scope UI
ScopeProvider
Wrap your admin with ScopeProvider to enable scope selection:
import {
AdminLayout,
AdminRouter,
ScopePicker,
ScopeProvider,
} from "@questpie/admin/client";
function AdminPage() {
return (
<ScopeProvider
headerName="x-selected-workspace"
storageKey="admin-workspace"
>
<AdminContent />
</ScopeProvider>
);
}| Prop | Type | Required | Description |
|---|---|---|---|
headerName | string | Yes | HTTP header for scope ID (must match context.ts) |
storageKey | string | No | localStorage key for persistence |
defaultScope | string | null | No | Default scope if none stored |
ScopePicker
A dropdown for selecting the current scope. Place it in the sidebar:
<AdminLayout
admin={admin}
basePath="/admin"
slots={{
afterBrand: (
<div className="px-3 py-2 border-b">
<ScopePicker
collection="workspaces"
labelField="name"
placeholder="Select workspace..."
allowClear
clearText="All Workspaces"
compact
/>
</div>
),
}}
>
<AdminRouter basePath="/admin" />
</AdminLayout>Three data source modes:
// From a collection
<ScopePicker collection="workspaces" labelField="name" />
// Static options
<ScopePicker options={[
{ value: "ws_1", label: "Workspace 1" },
{ value: "ws_2", label: "Workspace 2" },
]} />
// Async loader
<ScopePicker loadOptions={async () => {
const res = await fetch("/api/my-workspaces");
return res.json();
}} />useScopedFetch
Inject the scope header into all API calls automatically:
import { useScopedFetch } from "@questpie/admin/client";
function AdminContent() {
const scopedFetch = useScopedFetch();
const client = useMemo(
() => createClient<typeof app>({ baseURL: "/api", fetch: scopedFetch }),
[scopedFetch],
);
return <AdminProvider client={client} />;
}Validating Access
In production, verify the user belongs to the selected scope:
export default context(async ({ request, session, db }) => {
const workspaceId = request.headers.get("x-selected-workspace");
if (workspaceId && session?.user) {
const isMember = await db.query.workspaceMembers.findFirst({
where: and(
eq(workspaceMembers.workspaceId, workspaceId),
eq(workspaceMembers.userId, session.user.id),
),
});
if (!isMember) throw new Error("Unauthorized");
}
return { workspaceId: workspaceId || null };
});Request-Scoped Services
For advanced isolation, use a request-scoped service:
import { service } from "questpie";
export default service({
lifecycle: "request",
deps: ["db", "session"] as const,
create: ({ db, session }) => createScopedDb(db, session?.user?.tenantId),
dispose: (scopedDb) => scopedDb.release(),
});Request Flow
1. User selects "Acme Corp" in ScopePicker
2. ScopeProvider stores scopeId in state + localStorage
3. useScopedFetch() adds header: x-selected-workspace: ws_123
4. Server context.ts extracts workspaceId from header
5. Access rules return where-clause filter: { workspace: { equals: "ws_123" } }
6. Only Acme Corp's data returnedNext Steps
- Access Control — where-clause filters and role-based rules
- Services — request-scoped services for tenant isolation
- Authentication — session-based auth with Better Auth