Multi-tenant
Per-tenant data isolation via request-scoped query conditions.
The multi-tenant plugin isolates data per tenant. Your authMiddleware returns a tenantSlug, and the plugin scopes every query and stamps every insert with it. The core routes stay tenant-agnostic.
Installation
Add it to your config
import { createCMS } from '@createcms/core';
import { multiTenant, type MultiTenantMiddlewareResult } from '@createcms/core/plugins/multi-tenant';
export const cms = createCMS({
db,
collections,
media,
plugins: [multiTenant()],
authMiddleware: async (ctx): Promise<MultiTenantMiddlewareResult> => {
const session = await getSession(ctx);
return { userId: session.userId, tenantSlug: session.organizationSlug };
},
});Type your authMiddleware return as MultiTenantMiddlewareResult so the compiler enforces the tenantSlug field.
Update the database
The plugin adds a tenantSlug column (NOT NULL) to several tables. Regenerate the schema file, then apply it to your database with your Drizzle migration workflow:
npx createcms generate
npx drizzle-kit generate && npx drizzle-kit migratecreatecms generate only writes the schema file; the drizzle-kit step applies it. Skipping the migration leaves the new columns absent and tenant queries failing. The drizzle-kit commands need a drizzle.config.ts at your project root — see the Quickstart for the one to create.
Usage
Tenant isolation is automatic once installed. Reads and inserts on the base tables (roots, assets, asset_folders, redirects, templates, variables) are filtered and stamped by the tenantSlug your authMiddleware returns; child tables (branches, commits, merge requests, publications) are isolated transitively through their tenant-scoped root. There is no client-side tenant state.
To resolve the tenant inside your authMiddleware, use resolveTenantSlug. By default it is session-only: it returns the fallback you pass (the tenant from the session) and ignores any tenantSlug in the request body or query, so a caller cannot scope a request to a tenant they don't belong to.
import { resolveTenantSlug } from '@createcms/core/plugins/multi-tenant';
// Session-only: the request cannot override the tenant.
const tenant = resolveTenantSlug(ctx, session.organizationSlug);To let a request supply the tenant — for admin or cross-tenant tooling — opt in with { allowRequestOverride: true }, and only after you have verified the caller is an admin. Passing it unconditionally lets any user read or write another tenant's data:
const tenant = resolveTenantSlug(ctx, session.organizationSlug, {
allowRequestOverride: session.isAdmin,
});See Security → Multi-tenant for the isolation rationale.
Variables and templates
Variables and templates are partitioned per tenant, exactly like content: each tenant has its own values (a companyName, a block default), independent of every other tenant's. Uniqueness for both is scoped per tenant, so a variable's key (or a template's (collection, blockType, propertyKey)) is free to differ per tenant.
Schema
The plugin adds a tenantSlug column (text, not null) and tenant-scoped indexes to these core tables:
| Table | Column | Notable indexes |
|---|---|---|
roots | tenantSlug | (tenantSlug, collection), unique (tenantSlug, collection, parentRootId, slug) |
assets | tenantSlug | (tenantSlug), unique (tenantSlug, slug) |
asset_folders | tenantSlug | unique (tenantSlug, parentId, name) |
redirects | tenantSlug | (tenantSlug, collection), unique (tenantSlug, sourceRootId) where archived_at IS NULL |
templates | tenantSlug | (tenantSlug, collection, blockType) |
variables | tenantSlug | (tenantSlug, key) |
Options
multiTenant() takes no configuration; every request is scoped by whatever tenantSlug your authMiddleware returns (see Usage).
resolveTenantSlug(ctx, fallback, opts)
| Option | Type | Default | Description |
|---|---|---|---|
allowRequestOverride | boolean | false | Let a request-supplied tenantSlug (body or query) override the session fallback. Only enable after verifying the caller is authorized for cross-tenant access. |
Error codes
| Code | Status | When |
|---|---|---|
TENANT_SLUG_REQUIRED | 400 | authMiddleware did not return a tenantSlug. |