QUESTPIE
Guides

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.

View markdown

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.

src/questpie/server/config/app.ts
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.

src/questpie/server/collections/posts.ts
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.

src/questpie/server/globals/site-settings.ts
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.

src/routes/admin.tsx
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 database

Pick 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 wantUseStorage
Per-tenant rowsscope field, access rules, a beforeValidate stampone table, filtered per request
Per-tenant settingsglobal().options({ scoped })one row per scope
Per-tenant membership or rolesa join collection, itself scopedone table
Shared identitythe built-in user collection, left aloneone table, no scope

Where each topic lives

TopicPage
The resolver in full, reserved keys, jobs and scriptsThe scope resolver
Every prop on the provider, the hooks and the pickerThe admin scope picker
Relations, the shared user table, system modeWhere isolation leaks

Next

Access control is the rule model this page leans on, including everything else a returned where can do.

On this page