life coded

a blog by Maximilian Ehlers

Multi Tenant React.js starter

Let me start this article with the requirements that it is going to adress, so you know if you want to continue.

  1. You have multiple tenants (websites)
  2. the clients should share components between each other to increase development speed of new tenants
  3. SSR has to be supported
  4. build configs are the same, but can be extended

Since these are quite a few things and a lot of good stuff is already open source I am only going to focus on what needs to be added onto the great react-starter-kit.
The repo will be available for you as a starter-kit as well, so you do not need to follow along in code if you do not want to. But make sure to read the article to understand what is happening.
I will take some opinionated approaches. Feel free to send me PRs though if you find stuff to improve!

Okay, lets dig in.

Multiple Tenants

After you have cloned the react-starter-kit you will have the following folder structure

.editorconfig
.eslintrc.js
.flowconfig
.git
.gitattributes
.gitignore
.nycrc
.stylelintrc.js
.travis.yml
CHANGELOG.md
CONTRIBUTING.md
Dockerfile
LICENSE.txt
README.md
docs
jest
jest.config.js
package.json
public
src
test
tools
yarn.lock

So far so good. Our code will still reside in the src folder which looks like this:

MUtils.js
client.js
components
config.js
createFetch.js
data
history.js
passport.js
router.js
routes
server.js

To have multiple tenants with different components and configs we start by creating a new folder with some subfolders in there.

We want to have two tenants a and b so lets run the following:

mkdir -p tenants/{a,b}

# folder structure
# tenants
# ├── a
# └── b

Now that we have the folders lets copy all the stuff under src into both folders and then remove what we copied from src.

Creating tenant a

First off lets update the file src/tenants/a/routes/Home.js with the following content

import React from 'react';

const TenantA = () =>
  <div>
    <h1>Tenant A</h1>
  </div>;

export default TenantA;

Then edit the config file and change the port. I use 3005.

Updating the tasks

Right now the config from react-starter-kit wont pick up our Tenant when starting it with yarn start.
To get this working we need to add an entrypoint to webpack, and a new npm task.

Inside of tools/webpack.configs.js we can change the clientConfig from a simple Object to a function that takes a tenant as a parameter and then uses the tenant to start at the correct entrypoint. Sames goes for the serverConfig

The changed parts of the file looks like this:

const clientConfig = (tenant) => ({
  ...config,

  name: 'client',
  target: 'web',

  entry: {
    client: ['babel-polyfill', `./src/tenants/${tenant}/client.js`],
  },
  .
  .
  .
  .
  .

const serverConfig = tenant => ({
  ...config,

  name: 'server',
  target: 'node',

  entry: {
    server: ['babel-polyfill', `./src/tenants/${tenant}/server.js`],
  },
  .
  .
  .
  .
  export default [clientConfig, serverConfig];

  export function createTenantConfig(tenant) {
    return [clientConfig(tenant), serverConfig(tenant)];
  }

Make sure to add () around the clientConfig and serverConfig Objects, so that it is not interpreted as the function body.

Now that we can pass in the tenant, we need to create a new script under tools/ that will bundle our files with the correct tenant.

Create tools/bundleTenantA.js and add this code:

import webpack from 'webpack';
import webpackConfig, {createTenantConfig} from './webpack.config';

/**
 * Creates application bundles from the source files.
 */
function bundle() {
  return new Promise((resolve, reject) => {
    webpack(createTenantConfig('a')).run((err, stats) => {
      if (err) {
        return reject(err);
      }

      console.info(stats.toString(webpackConfig[0].stats)); return resolve();
    });
  });
}

export default bundle;

This is very similar to bundle.js but with the extra import for creating our tenant specific config and the call of it.

Now we can finally add a new task to package.json. Just follow the example of all the other tasks and add

"bundle:tenanta": "babel-node tools/run bundleTenantA"

Bundling for our tenant is now almost working :)

Developing Tenant a

In order to see our Tenant in the dev environment in the browser we need to create a new start task as well.

First we update the tools/start script.

On line 73 and 120 we use the createTenantConfig again, after we imported it, and change to start function to accept a parameter.

Here are the changes:

import { createTenantConfig } from './webpack.config';
.
.
.
async function start(tenant) {
.
.

const clientConfig = createTenantConfig(tenant).find(
  config => config.name === 'client',
);
.
.
.

const serverConfig = createTenantConfig(tenant).find(
  config => config.name === 'server',
);
  .
  .
  .
const multiCompiler = webpack(createTenantConfig(tenant));

Creating a wrapper script

Now create a wrapper scripts at tools/startTenantA.js.
The code is easy and just executes the start script with the correct tenant.


import start from './start';

const startTenantA = async () => start('a');

export default startTenantA;

Now we just need to add this to the package.json, and we are ready to go:

"start:tenanta": "babel-node tools/run startTenantA",

Run yarn start:tenanta and you should be greeted by the starter site with Tenant <h1>.

Reaping the benefits

Now you can repeat the steps to create a Tenant b. When you are done we can finally see why this approach is helpful when managing multiple tenants.

Using a shared component

Update both routes/home/Homes.js like this:

import React from 'react';
import Shared from '../../../../SharedComponents/Shared';

const Tenant = () =>
  <div>
    <Shared />
    <h1>Tenant </h1>
  </div>;

export default Tenant;

and then create src/SharedComponents/Shared.js:

import React from 'react';

export default () => <div>SHARED</div>;

Now when you start the different tenants they will be using a shared component.

The Benefits

Why use this approach and not f.e. put all shared components in a library?
Easy, less problems. Whatever team works on this repo will know if something will break when they change a shared component, because they can run all the tests.
Also all teams will see the PRs.

If you really need to add some behaviour to a component that no one else needs? Copy and paste, you can still use the cool shared stuff.

Keeping libraries up to date and seeing how they affect your own repo is much more tedious and includes a lot of headache, like npm link breaking with some webpack configs, or babel not finding the correct loaders if you would like to share building tools.

In my opinion a big repo is not a problem. All builds can still be specialized by the scripts, just push in some new plugins or loaders etc..
It also makes bootstrapping new sites so much faster, because almost everything is already there. Including a lot of React components.

Verdict

If you have multiple teams in your company, that all builds similar sites, go for it.

You do not have to use react-starter-kit but using such a mono repo for react based stuff can really save from some troubles I have experienced with extracting everything into libraries.

The code

You can find all the code I changed under https://github.com/bananenmannfrau/multi-tenant-react-starter.

Feel free to send PRs. For example shared components are still lacking the ability to use specific css files for the tenants.
A custom webpack loader could be used to solve this.