Simplify your stack and build anything. Or everything.
Build tomorrow’s web with a modern solution you truly own.
Code-based nature means you can build on top of it to power anything.
It’s time to take back your content infrastructure.

Multi-Tenant Plugin

https://www.npmjs.com/package/@payloadcms/plugin-multi-tenant

This plugin sets up multi-tenancy for your application from within your Admin Panel. It does so by adding a tenant field to all specified collections. Your front-end application can then query data by tenant. You must add the Tenants collection so you control what fields are available for each tenant.

Core features

  • Adds a tenant field to each specified collection
  • Adds a tenant selector to the admin panel, allowing you to switch between tenants
  • Filters list view results by selected tenant
  • Filters relationship fields by selected tenant
  • Ability to create "global" like collections, 1 doc per tenant
  • Automatically assign a tenant to new documents
  • Rejects writes that assign a tenant the user is not a member of

Installation

Install the plugin using any JavaScript package manager like pnpm, npm, or Yarn:

1
pnpm add @payloadcms/plugin-multi-tenant

Options

The plugin accepts an object with the following properties:

1
type MultiTenantPluginConfig<ConfigTypes = unknown> = {
2
/**
3
* Base path for your application
4
*
5
* https://nextjs.org/docs/app/api-reference/config/next-config-js/basePath
6
*
7
* @default undefined
8
*/
9
basePath?: string
10
/**
11
* After a tenant is deleted, the plugin will attempt to clean up related documents
12
* - removing documents with the tenant ID
13
* - removing the tenant from users
14
*
15
* @default true
16
*/
17
cleanupAfterTenantDelete?: boolean
18
/**
19
* Automatically
20
*/
21
collections: {
22
[key in CollectionSlug]?: {
23
/**
24
* Override the access result from the collection access control functions
25
*
26
* The function receives:
27
* - accessResult: the original result from the access control function
28
* - accessKey: 'read', 'create', 'update', 'delete', 'readVersions', 'unlock', or 'validate'
29
* - ...restOfAccessArgs: the original arguments passed to the access control function
30
*/
31
accessResultOverride?: CollectionAccessResultOverride
32
/**
33
* Opt out of adding the tenant field and place
34
* it manually using the `tenantField` export from the plugin
35
*
36
* Tenant membership is still enforced on write. See Tenant write enforcement.
37
*/
38
customTenantField?: boolean
39
/**
40
* Set to `true` if you want the collection to behave as a global
41
*
42
* @default false
43
*/
44
isGlobal?: boolean
45
/**
46
* Overrides for the tenant field, will override the entire tenantField configuration
47
*/
48
tenantFieldOverrides?: CollectionTenantFieldConfigOverrides
49
/**
50
* Set to `false` if you want to manually apply the baseListFilter
51
* Set to `false` if you want to manually apply the baseFilter
52
*
53
* @default true
54
*/
55
useBaseFilter?: boolean
56
/**
57
* @deprecated Use `useBaseFilter` instead. If both are defined,
58
* `useBaseFilter` will take precedence. This property remains only
59
* for backward compatibility and may be removed in a future version.
60
*
61
* Originally, `baseListFilter` was intended to filter only the List View
62
* in the admin panel. However, base filtering is often required in other areas
63
* such as internal link relationships in the Lexical editor.
64
*
65
* @default true
66
*/
67
useBaseListFilter?: boolean
68
/**
69
* Set to `false` if you want to handle collection access manually without the multi-tenant constraints applied
70
*
71
* @default true
72
*/
73
useTenantAccess?: boolean
74
}
75
}
76
/**
77
* Enables debug mode
78
* - Makes the tenant field visible in the admin UI within applicable collections
79
*
80
* @default false
81
*/
82
debug?: boolean
83
/**
84
* Enables the multi-tenant plugin
85
*
86
* @default true
87
*/
88
enabled?: boolean
89
/**
90
* Localization for the plugin
91
*/
92
i18n?: {
93
translations: {
94
[key in AcceptedLanguages]?: {
95
/**
96
* Shown inside 3 dot menu on edit document view
97
*
98
* @default 'Assign Tenant'
99
*/
100
'assign-tenant-button-label'?: string
101
/**
102
* Shown as the title of the assign tenant modal
103
*
104
* @default 'Assign "{{title}}"'
105
*/
106
'assign-tenant-modal-title'?: string
107
/**
108
* Shown as the label for the assigned tenant field in the assign tenant modal
109
*
110
* @default 'Assigned Tenant'
111
*/
112
'field-assignedTenant-label'?: string
113
/**
114
* Shown as the label for the global tenant selector in the admin UI
115
*
116
* @default 'Filter by Tenant'
117
*/
118
'nav-tenantSelector-label'?: string
119
}
120
}
121
}
122
/**
123
* Field configuration for the field added to all tenant enabled collections
124
*/
125
tenantField?: RootTenantFieldConfigOverrides
126
/**
127
* Field configuration for the field added to the users collection
128
*
129
* If `includeDefaultField` is `false`, you must include the field on your users collection manually
130
* This is useful if you want to customize the field or place the field in a specific location
131
*/
132
tenantsArrayField?:
133
| {
134
/**
135
* Access configuration for the array field
136
*/
137
arrayFieldAccess?: ArrayField['access']
138
/**
139
* Name of the array field
140
*
141
* @default 'tenants'
142
*/
143
arrayFieldName?: string
144
/**
145
* Name of the tenant field
146
*
147
* @default 'tenant'
148
*/
149
arrayTenantFieldName?: string
150
/**
151
* When `includeDefaultField` is `true`, the field will be added to the users collection automatically
152
*/
153
includeDefaultField?: true
154
/**
155
* Additional fields to include on the tenants array field
156
*/
157
rowFields?: Field[]
158
/**
159
* Access configuration for the tenant field
160
*/
161
tenantFieldAccess?: RelationshipField['access']
162
}
163
| {
164
arrayFieldAccess?: never
165
arrayFieldName?: string
166
arrayTenantFieldName?: string
167
/**
168
* When `includeDefaultField` is `false`, you must include the field on your users collection manually
169
*/
170
includeDefaultField?: false
171
rowFields?: never
172
tenantFieldAccess?: never
173
}
174
/**
175
* Customize tenant selector label
176
*
177
* Either a string or an object where the keys are i18n codes and the values are the string labels
178
*
179
* @deprecated Use `i18n.translations` instead.
180
*/
181
tenantSelectorLabel?:
182
| Partial<{
183
[key in AcceptedLanguages]?: string
184
}>
185
| string
186
/**
187
* The slug for the tenant collection
188
*
189
* @default 'tenants'
190
*/
191
tenantsSlug?: string
192
/**
193
* Function that determines if a user has access to _all_ tenants
194
*
195
* Useful for super-admin type users
196
*/
197
userHasAccessToAllTenants?: (
198
user: ConfigTypes extends { user: unknown } ? ConfigTypes['user'] : User,
199
) => boolean
200
/**
201
* Override the access result on the users collection access control functions
202
*
203
* The function receives:
204
* - accessResult: the original result from the access control function
205
* - accessKey: 'read', 'create', 'update', 'delete', 'readVersions', 'unlock', or 'validate'
206
* - ...restOfAccessArgs: the original arguments passed to the access control function
207
*/
208
usersAccessResultOverride?: CollectionAccessResultOverride
209
/**
210
* Opt out of adding access constraints to the tenants collection
211
*/
212
useTenantsCollectionAccess?: boolean
213
/**
214
* Opt out including the baseListFilter to filter tenants by selected tenant
215
*/
216
useTenantsListFilter?: boolean
217
218
/**
219
* Opt out including the baseListFilter to filter users by selected tenant
220
*/
221
useUsersTenantFilter?: boolean
222
}

Basic Usage

In the plugins array of your Payload Config, call the plugin with options:

1
import { buildConfig } from 'payload'
2
import { multiTenantPlugin } from '@payloadcms/plugin-multi-tenant'
3
import type { Config } from './payload-types'
4
5
const config = buildConfig({
6
collections: [
7
{
8
slug: 'tenants',
9
admin: {
10
useAsTitle: 'name',
11
},
12
fields: [
13
// remember, you own these fields
14
// these are merely suggestions/examples
15
{
16
name: 'name',
17
type: 'text',
18
required: true,
19
},
20
{
21
name: 'slug',
22
type: 'text',
23
required: true,
24
},
25
{
26
name: 'domain',
27
type: 'text',
28
required: true,
29
},
30
],
31
},
32
],
33
plugins: [
34
multiTenantPlugin<Config>({
35
collections: {
36
pages: {},
37
navigation: {
38
isGlobal: true,
39
},
40
},
41
}),
42
],
43
})
44
45
export default config

Tenant write enforcement

Every create and update to a tenant-enabled collection is checked. If the request assigns a tenant that the user is not a member of, the write is rejected with a validation error on the tenant field.

This applies to the tenant a document starts in and the tenant it moves to. A user cannot create a new document in another tenant or move an existing document to another tenant.

The check runs on drafts and autosave saves as well as ordinary saves. Payload skips field validation for those operations, so the plugin also enforces membership in a collection beforeValidate hook.

What is checked

The tenant is compared with the tenants assigned to the user on the admin users collection. The check is skipped when:

  • The user passes your userHasAccessToAllTenants function
  • The user belongs to a different auth collection. These users do not have a tenants array, which matches how the plugin's access control treats them
  • The tenant is unchanged. An update that does not change the tenant field is not blocked by this check
  • The call comes from the Local API with no user, such as a seed script or a migration

All other writes are checked, including requests with no user. An unauthenticated request belongs to no tenant, so it cannot assign one. If a collection allows public creates and must set a tenant, do this in server-side code through the Local API instead of the request body.

Other auth collections

Users from an auth collection other than the tenant-managed collection cannot write the tenant field. Attempts to set or change the tenant are ignored. These users can still update other fields if the collection access rules allow it. This applies when the plugin adds the field, or when a customTenantField configuration passes adminUsersSlug to tenantField.

filterOptions and write permission

filterOptions on the tenant field controls which tenants the admin panel shows. It does not control which tenants the server accepts on write.

If you override filterOptions to show more tenants, the additional tenants are still rejected on save. Use userHasAccessToAllTenants to let a user write outside their assigned tenants.

The write check reads the user's tenant assignments instead of running the filterOptions query. This avoids a database read in the same transaction as the write.

Collections that place the tenant field themselves

Collections that use customTenantField receive the same collection-hook check. The field checks the user's explicit tenant assignments. If users depend on userHasAccessToAllTenants, pass that function to tenantField when you place it. The collection hook applies the check in all cases.

Access control also applies. A user who cannot update a document is rejected before the tenant is checked.

Front end usage

The plugin scaffolds out everything you will need to separate data by tenant. You can use the tenant field to filter data from enabled collections in your front-end application.

In your frontend you can query and constrain data by tenant with the following:

1
const pagesBySlug = await payload.find({
2
collection: 'pages',
3
depth: 1,
4
draft: false,
5
limit: 1000,
6
overrideAccess: false,
7
where: {
8
// your constraint would depend on the
9
// fields you added to the tenants collection
10
// here we are assuming a slug field exists
11
// on the tenant collection, like in the example above
12
'tenant.slug': {
13
equals: 'gold',
14
},
15
},
16
})

NextJS rewrites

Using NextJS rewrites and this route structure /[tenantDomain]/[slug], we can rewrite routes specifically for domains requested:

1
async rewrites() {
2
return [
3
{
4
source: '/((?!admin|api)):path*',
5
destination: '/:tenantDomain/:path*',
6
has: [
7
{
8
type: 'host',
9
value: '(?<tenantDomain>.*)',
10
},
11
],
12
},
13
];
14
}

React Hooks

Below are the hooks exported from the plugin that you can import into your own custom components to consume.

useTenantSelection

You can import this like so:

1
import { useTenantSelection } from '@payloadcms/plugin-multi-tenant/client'
2
3
...
4
5
const tenantContext = useTenantSelection()

The hook returns the following context:

1
type ContextType = {
2
/**
3
* Array of options to select from
4
*/
5
options: OptionObject[]
6
/**
7
* The currently selected tenant ID
8
*/
9
selectedTenantID: number | string | undefined
10
/**
11
* Prevents a refresh when the tenant is changed
12
*
13
* If not switching tenants while viewing a "global",
14
* set to true
15
*/
16
setPreventRefreshOnChange: React.Dispatch<React.SetStateAction<boolean>>
17
/**
18
* Sets the selected tenant ID
19
*
20
* @param args.id - The ID of the tenant to select
21
* @param args.refresh - Whether to refresh the page
22
* after changing the tenant
23
*/
24
setTenant: (args: {
25
id: number | string | undefined
26
refresh?: boolean
27
}) => void
28
}

Examples

The Examples Directory also contains an official Multi-Tenant example.

Was this page helpful?

Next

Nested Docs Plugin