Skip to content

Multi-tenant ​

What you'll learn

How to run several sites or clients in one CMS: each tenant with its own content, members and settings, a tenant switcher in the admin, and frontends that read one tenant.

Before this page: Access control, Roles and Plugins.

@easy-cms/plugin-multi-tenant adds tenants to one CMS, one database and one admin. A tenant is a brand, a site, a branch or a client. Each has its own documents in the collections you name, its own value of the globals you name, and its own members, each with a role there. Everything else is shared.

It suits a company with several brands or sites, and an agency that runs its clients' sites: the team works across tenants, each client only in theirs. If clients must not share a database at all, run one CMS per client instead.

Set it up ​

bash
npm install @easy-cms/plugin-multi-tenant
bash
pnpm add @easy-cms/plugin-multi-tenant
bash
yarn add @easy-cms/plugin-multi-tenant
bash
bun add @easy-cms/plugin-multi-tenant
ts
import { multiTenantPlugin } from '@easy-cms/plugin-multi-tenant'

export default defineConfig({
  // …
  auth: { rbac: true }, // optional: roles from the admin, given per tenant
  plugins: [
    multiTenantPlugin({
      collections: ['posts', 'pages', 'media'], // each document belongs to one tenant
      globals: ['site-settings'], // one value per tenant
    }),
  ],
})

The plugin adds:

  • Settings › Users & access › Tenants: name, slug and the domains of each tenant.
  • A tenant field on each collection you name, set from the tenant you work in. With media and upload.folders, media folders belong to a tenant too.
  • A tenant switcher at the top of the admin's menu, for people in more than one tenant.
  • Settings › Users & access › Members: the people of the chosen tenant and their role there.
  • tenants on users: the tenants each person is in, and their role in each.

Then create the database's changes as usual: npx easy-cms migrate:create tenants. For a site that already has content, give it to a tenant once:

bash
npx easy-cms tenants:assign brand-a

Who sees what ​

WhoSees and changes
Users with access to all tenants (role admin by default)Every tenant, or the one they choose; tenants, Settings, backups, roles.
MembersThe tenant they work in, with their role there: one of their tenants, chosen in the switcher.
An admin of a tenant (role admin there)That tenant's content and members; not Settings → Roles, Backups, Email or other tenants.
VisitorsThe tenant their request names; nothing when it names none.
  • New documents get the tenant the request works in. Only users with access to all tenants choose another, or move a document.
  • Relationships and uploads pointing to tenant collections offer and accept that tenant's documents only. A document a plugin writes without a tenant (a form's submission, a page's redirect) takes the tenant of what it points to.
  • Media folders belong to a tenant; an upload field's folder: 'banners' opens each tenant's own banners folder.
  • Unique values, slugs included, only need to differ within a tenant: two brands can both have /posts/hello.
  • People are added under Members, by email: an account can be in several tenants. New people get an email to set a password when email is set up. Admins of a tenant can't change people's accounts, only their membership. On someone's account, the staff of a tenant see that tenant's membership only; people see all of their own.
  • An API key keeps the tenant it was created in.
  • The audit log keeps each entry's tenant: admins of a tenant see its entries; users with access to all see everything, or the chosen tenant's. Checking the whole log's integrity is for them only.
  • While all tenants are shown, lists have a Tenant column; the switcher narrows them to one.

With other plugins ​

List their collections in collections to keep them per tenant, and add multiTenantPlugin after the plugins that make them:

ts
plugins: [
  nestedDocsPlugin({ collections: ['pages'] }),
  redirectsPlugin({ collections: ['pages'], url }),
  formBuilderPlugin(),
  multiTenantPlugin({
    collections: ['pages', 'redirects', 'forms', 'form-submissions', 'media'],
  }),
]
  • Nested pages: paths only differ within a tenant, so each can have /about. Pass the tenant to the helpers: findByPath(cms, 'pages', path, { context }) and getTree(cms, 'pages', { context }).
  • Redirects: each tenant has its own; a page that moves gets a redirect in its tenant. resolveRedirect(cms, url, { context }) applies the request's.
  • Forms: <easy-form> and /api/cms/form/<slug> find the form of the request's tenant (its domain or header), and submissions belong to it.
  • SEO: see below.

Here context is await tenantContext(cms, { host }) or (await cms.forRequest(request)).context.

List all of a plugin's collections or none: with forms per tenant but form-submissions shared (or some of the shop's collections), what the shared ones hold would cross tenants. The server logs a warning on the first request when that is the case.

Frontends ​

A request names its tenant, in this order:

  1. The x-easy-cms-tenant header, with the tenant's slug or id.
  2. ?tenant= in the URL.
  3. The admin's cookie (the switcher).
  4. Its domain: a tenant whose Domains has the request's host.
ts
// Another app or a static site
const posts = await fetch('https://cms.example.com/api/cms/posts', {
  headers: { 'x-easy-cms-tenant': 'brand-a' },
}).then((r) => r.json())

In Nuxt or Next.js pages, read with the Local API in the tenant of the page's domain:

ts
import { tenantContext } from '@easy-cms/plugin-multi-tenant'

const context = await tenantContext(cms, { host: request.headers.get('host') ?? '' })
const posts = await cms.find('posts', { overrideAccess: false, user: null, context })
const site = await cms.findGlobal('site-settings', { overrideAccess: false, user: null, context })

tenantContext(cms, { slug }) works with a slug from the URL, e.g. /[tenant]/[slug] routes. findTenantFor(cms, { slug | host }) gives the tenant itself, or null. When you changed slugs.tenants, pass it as collection: tenantContext(cms, { host, collection: 'sites' }). cms.forRequest(request) gives the user and context of a request as REST does.

With the SEO plugin, /sitemap.xml and /llms.txt list the pages of the tenant of the requesting domain. Its helpers take the same context: sitemap(cms, { context: await tenantContext(cms, { host }) }).

A request that names no tenant finds nothing in tenant collections. With publicReads: 'all', it finds every tenant's instead.

Options ​

OptionDefault
collections—Required. Collections whose documents belong to one tenant.
globals[]Globals with a value per tenant.
slugs.tenantstenantsThe tenants collection; declare it yourself to add fields.
userHasAccessToAllTenantsrole admin(user) => boolean: who sees every tenant and the system.
publicReadsnoneall: requests that name no tenant read every tenant's. Writing still needs a tenant.
editShared[]Shared collections and globals the people of a tenant (its admins too) may change; a change applies to every tenant. Otherwise only users with access to all tenants change shared data.
headerx-easy-cms-tenantThe header that names the tenant.
cookieecms-tenantWhere the admin keeps the chosen tenant.

Deleting a tenant ​

Deleting a tenant under Settings › Users & access › Tenants deletes its documents and files in the tenant collections, and takes it off its members' lists. The admin says how much goes, and asks for the tenant's name typed before deleting. It can't be undone: make a backup first.

How it works ​

The plugin uses parts of the core you can use yourself:

  • onRequest in the config works out each request's context (here, the tenant) and the user within it (their role there, scoped). Access rules, hooks and filterOptions receive the context; the Local API takes one as an option. onRequest can be a list: the plugin adds its own after yours, sees the user yours returned, and merges its context into yours.
  • scoped users are admins of their part only: Settings, backups, roles and other people's API keys need an admin of the whole system.
  • Globals with scope keep a value per scope.
  • uniqueWithin works on any unique field, with a unique index per scope in the database.
  • admin.switcher adds the choice at the top of the menu.
  • audit.scope keeps each audit entry's part of the site.
  • admin.confirmDelete, admin.initialValue, admin.column and admin.allowCreate shape deleting, new documents and lists in the admin.
  • filterOptions on upload fields narrow which files fit.
  • cms.uniqueScope() tells plugins what a unique field is unique within for a request.

Not yet: a backup or export of one tenant, webhooks per tenant, tenants that sign themselves up, and single sign-on that puts people in a tenant by their email's domain.

Next steps ​

  • Roles: what each role may do; members get one per tenant.
  • Access control: your own rules see the request's context.
  • API keys: keys for one tenant's scripts.

Released under the MIT License.