Plugins › Multi-tenant
Docs/Plugins/Multi-tenant

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

npm
npm install @easy-cms/plugin-multi-tenant
pnpm
pnpm add @easy-cms/plugin-multi-tenant
yarn
yarn add @easy-cms/plugin-multi-tenant
bun
bun add @easy-cms/plugin-multi-tenant
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:

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.
  • 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.
  • 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:

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.

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.
// 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:

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. 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.
tenantsSlugtenantsThe 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.
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.
  • 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.defaultValue, 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.