QUESTPIE
Operate in Production

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.

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

ParameterTypeDescription
requestRequestIncoming HTTP request (Web API)
session{ user, session } | nullResolved auth session
dbDatabaseDatabase 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:

collections/projects.ts
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 records
  • false — 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:

routes/admin/$.tsx
import {
	AdminLayout,
	AdminRouter,
	ScopePicker,
	ScopeProvider,
} from "@questpie/admin/client";

function AdminPage() {
	return (
		<ScopeProvider
			headerName="x-selected-workspace"
			storageKey="admin-workspace"
		>
			<AdminContent />
		</ScopeProvider>
	);
}
PropTypeRequiredDescription
headerNamestringYesHTTP header for scope ID (must match context.ts)
storageKeystringNolocalStorage key for persistence
defaultScopestring | nullNoDefault 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:

src/questpie/server/context.ts
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:

services/scoped-db.ts
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 returned

Next Steps

On this page