# Multi-tenancy (/docs/guides/multi-tenancy)

---
title: Multi-tenancy
description: 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.
kind: guide
package: questpie
---

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.

```ts title="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.

```ts title="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.

<Callout type="warn" title="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.
</Callout>

## 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.

```ts title="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.

```tsx title="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

```bash
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.

<Callout type="warn" title="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.
</Callout>

## 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](/docs/guides/multi-tenancy/scope-resolver)         |
| Every prop on the provider, the hooks and the picker  | [The admin scope picker](/docs/guides/multi-tenancy/admin-scope-picker) |
| Relations, the shared user table, system mode         | [Where isolation leaks](/docs/guides/multi-tenancy/isolation-gaps)      |

## Next

**[Access control](/docs/schema/access-control)** is the rule model this page
leans on, including everything else a returned `where` can do.
