Multitenancy
Organic CMS allows multiple domains to be served using the same codebase with minimal changes. The flexible setup allows to customise virtually everything based on the currently viewed domain: different colour scheme, custom WP blocks, different page templates, etc.
Even though multitenant frontend deployment of organic CMS is mostly similar to a single-tenant one, a few key differences are important to be aware of. This guide will cover them.
Setup structure
frontend/
|-- src/
|-- pages/
| |-- domains/
| |-- primary-website.com/
| | |-- show.page.tsx
| |-- secondary-website.org
| | |-- test.page.tsx
|-- middleware.page.ts
|-- theme.ts
The two most important pieces of logic are located in theme.ts
and middleware.page.ts
.
Middleware
Here is how the middleware file looks:
const middleware = (inputReq: NextRequest) => {
const req = redirectsMiddleware(inputReq);
if (req !== null) {
return req;
}
return multitenantMiddleware(inputReq);
};
export default middleware;
This handles the actual routing of requests to files on the disk. Let's try to break down a lifetime of a request to better Understand how this works
- User opens
https://www.primary-website.com/show
webpage multitenantMiddleware
is called, and the request is routed to files insidesrc/pages/domains/primary-website.com
show.page.tsx
is rendered.
multitenantMiddleware
applies several modifications to the original URL to determine the original domain.
It removes different commonly occurring prefixes. If that's not enough, the user can pass a custom hostnameRemap
object that will be consulted
after modifications.
Removed prefixes
localhost:3000
lcl.
stg.
www.
prodnew.
.builddemo
Theme
A special type of theme is used for multi-tenant deployments. It's called MultiTheme<ThemeId>
. To continue the example
of primary-website.com
, this is how the theme.ts
file could look like for it:
import createTheme from '@orgnc/core/lib/themes/createTheme';
const baseTheme = createTheme();
enum SiteId {
PRIMARY_WEBSITE = 'primary-website'
SECONDARY_WEBSITE = 'secondary-website'
}
export const multiTheme = {
base: baseTheme,
variations: {
[SiteId.PRIMARY_WEBSITE]: {
muiTheme: primaryColorScheme,
},
[SiteId.SECONDARY_WEBSITE]: {
muiTheme: secondaryColorScheme
},
},
} as MultiTheme<SiteId>;
Once the show.page.tsx
page is rendered for primary-website.com
website, the first variation is used for
rendering. Inside a variation, you can override any key of ITheme
. Variation is merged recursively with the base theme.
Every site must have its own variation
defined. Otherwise, the configuration for the first entry will be used.
Theme ID resolution logic
To figure out which variation to use, several modifications are applied to the original URL in the following order:
- The ones that are performed by
multitenantMiddleware
:https://www.primary-website.com/show
=>primary-website.com
- Common domains are dropped:
primary-website.com
=>primary-website
To customise this behaviour, one can provide a function getThemeIdByDomain
inside the MultiTheme
object. For further
information, see relevant section
Dropped domains
.com
.net
.org
How to access theme in different handlers?
All helpers follow the same theme id resolution logic as explained in this section
React Components
To get access to the theme object inside React components, you can use the same approach as in the single-tenant setup:
import { useSiteSpecificComponents } from '@orgnc/core/hooks';
export const MyComponent: React.FunctionComponent = () => {
const theme = useSiteSpecificComponents();
// Do something with theme
//
// Tip: Current variation ID can be accessed via theme.themeId
return (
<>...</>
)
};
API Handlers
API-only handlers are dynamic by their nature. We can use a helper method to create a theme based on the incoming request.
import { getThemeByRequest } from '@orgnc/core/lib/themes/multiTheme';
export default async function apiHandlers(
req: NextApiRequest,
res: NextApiResponse
) {
const theme = getThemeByRequest(req, multiTheme)
return "ok"
}
Next Handlers
import { getThemeByResolvedURL } from '@orgnc/core/lib/themes/multiTheme';
export const getServerSideProps: GetServerSideProps = async (props) => {
const theme = getThemeByResolvedURL(props.resolvedUrl, multiTheme);
return {};
};
How to apply custom logic for hostname and theme matching?
When the default theme resolution logic is not enough, the user can override it and provide a custom implementation. This is especially useful when some custom routing needs to happen.
export const multiTheme = {
base: baseTheme,
variations: {
[SiteId.PRIMARY_WEBSITE]: {
muiTheme: primaryColorScheme,
},
[SiteId.SECONDARY_WEBSITE]: {
muiTheme: secondaryColorScheme
},
},
// NB: multitenantMiddleware modifications are applied before calling this method
getThemeIdByDomain: (domain, multiThemeProp) => {
const defaultThemeId = Object.keys(multiThemeProp.variations)[0] as SiteId;
if (!domain) {
return defaultThemeId;
}
switch (domain) {
'primary-website.com':
return SiteId.PRIMARY_WEBSITE;
'secondary-website.org':
return SiteId.SECONDARY_WEBSITE;
// Custom domain remapping
'lcl-test.secondary-website.org':
return SiteId.SECONDARY_WEBSITE;
'www-prod.another-website.net':
return SiteId.SECONDARY_WEBSITE;
}
},
} as MultiTheme<SiteId>;
Dynamic Config
Organic CMS provides a feature that, based on the current theme, calls a different backend API instance.
There are several steps required to make it work
Step 1: Enable feature flag in theme
const baseTheme = createTheme({
graphqlClient: {
useMultiTenantConfig: true,
},
redisCache: {
useMultiTenantConfig: true,
},
});
Step 2: Create per-env config files
We prefer to store per-env configs on disk in a special folder. Here is a proposed file structure:
frontend/
|-- src/
|-- config/
| |-- default.json
| |-- staging.json
| |-- production.json
The file name is the name of an environment.
All files in the config
directory follow the same pattern:
{
"wpMultiTenantDomain": {
"primary-website": "lcl-cms.primary-website.com",
"secondary-website": "lcl-cms.secondary-website.org",
},
"redisKeyPrefix": {
"primary-website": "primary-lcl-web",
"secondary-website": "secondary-lcl-web",
}
}
Every theme variant must have an entry in wpMultiTenantDomain
and redisKeyPrefix
.
Step 3: Propagate config files to the app
To propagate data from config files, we need to modify the next.config.js
file in the root of a frontend project:
const { withSentryConfig } = require('@sentry/nextjs');
const envConfig = require('config');
/**
* @type {import('next').NextConfig}
* */
const nextConfig = {
// [...] Other settings
publicRuntimeConfig: {
// [...] Other settings
wpDomain: process.env.WP_DOMAIN,
wpMultitenantDomain: envConfig.get('wpMultiTenantDomain'),
},
serverRuntimeConfig: {
// ... Other settings
wpDomain: process.env.CONTAINER_WP_DOMAIN,
redisHost: process.env.REDIS_HOST,
multitenantRedisKeyPrefix: envConfig.get('redisKeyPrefix'),
},
};
To try out different envs locally, the user can override NODE_CONFIG_ENV
env varible. For example, setting
NODE_CONFIG_ENV=production
will force the app to use prod config.