⚠️ Work in progress — createCMS is pre-1.0 and not production-ready (not tested in production). Expect breaking changes.
createCMS

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

lib/cms.ts
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 migrate

createcms 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:

TableColumnNotable indexes
rootstenantSlug(tenantSlug, collection), unique (tenantSlug, collection, parentRootId, slug)
assetstenantSlug(tenantSlug), unique (tenantSlug, slug)
asset_folderstenantSlugunique (tenantSlug, parentId, name)
redirectstenantSlug(tenantSlug, collection), unique (tenantSlug, sourceRootId) where archived_at IS NULL
templatestenantSlug(tenantSlug, collection, blockType)
variablestenantSlug(tenantSlug, key)

Options

multiTenant() takes no configuration; every request is scoped by whatever tenantSlug your authMiddleware returns (see Usage).

resolveTenantSlug(ctx, fallback, opts)

OptionTypeDefaultDescription
allowRequestOverridebooleanfalseLet 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

CodeStatusWhen
TENANT_SLUG_REQUIRED400authMiddleware did not return a tenantSlug.

On this page