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 install @easy-cms/plugin-multi-tenantpnpm add @easy-cms/plugin-multi-tenantyarn add @easy-cms/plugin-multi-tenantbun add @easy-cms/plugin-multi-tenantimport { 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
tenantfield on each collection you name, set from the tenant you work in. Withmediaandupload.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.
tenantson 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-aWho sees what
| Who | Sees and changes |
|---|---|
Users with access to all tenants (role admin by default) | Every tenant, or the one they choose; tenants, Settings, backups, roles. |
| Members | The 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. |
| Visitors | The 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 ownbannersfolder. - 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:
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 })andgetTree(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:
- The
x-easy-cms-tenantheader, with the tenant's slug or id. ?tenant=in the URL.- The admin's cookie (the switcher).
- 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. 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
| Option | Default | |
|---|---|---|
collections | — | Required. Collections whose documents belong to one tenant. |
globals | [] | Globals with a value per tenant. |
slugs.tenants | tenants | The tenants collection; declare it yourself to add fields. |
userHasAccessToAllTenants | role admin | (user) => boolean: who sees every tenant and the system. |
publicReads | none | all: 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. |
header | x-easy-cms-tenant | The header that names the tenant. |
cookie | ecms-tenant | Where 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:
onRequestin the config works out each request'scontext(here, the tenant) and the user within it (their role there,scoped). Access rules, hooks andfilterOptionsreceive thecontext; the Local API takes one as an option.onRequestcan be a list: the plugin adds its own after yours, sees the user yours returned, and merges itscontextinto yours.scopedusers are admins of their part only: Settings, backups, roles and other people's API keys need an admin of the whole system.- Globals with
scopekeep a value per scope. uniqueWithinworks on any unique field, with a unique index per scope in the database.admin.switcheradds the choice at the top of the menu.audit.scopekeeps each audit entry's part of the site.admin.confirmDelete,admin.initialValue,admin.columnandadmin.allowCreateshape deleting, new documents and lists in the admin.filterOptionson 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.