Skip to main content

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

  1. User opens https://www.primary-website.com/show webpage
  2. multitenantMiddleware is called, and the request is routed to files inside src/pages/domains/primary-website.com
  3. show.page.tsx is rendered.
note

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.

warning

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:

  1. The ones that are performed by multitenantMiddleware: https://www.primary-website.com/show => primary-website.com
  2. 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?

note

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",
}
}
warning

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'),
},
};
note

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.