DEV Community

Cover image for Building a Multi-Tenant React App. Part 2: Dynamic Routes
José Del Valle
José Del Valle

Posted on • Originally published at delvalle.dev on

Building a Multi-Tenant React App. Part 2: Dynamic Routes

Hello! Welcome to the second part of my Multi-Tenant React App series. In the first part we took a look at how to set up the project and implemented some simple multi-tenancy features.

This series is inspired in a real-world problem that I faced a couple of years ago -although a bit more complex than this-. My team and I had to develop a multi-tenant app that could look and behave differently based con client-specific configuration coming from the API. Still, most of the layout, styles, and features would be the same for all the clients.

Imagine now that Client A wants to have a home page in their root route that will show a list of their products, whereas Client B wants to show a featured product page in their root route. Both clients want an about page and didn't request any custom look or feature in it.

This would mean two very different components to show in the root route and one common component for the about.

Let's leverage our current architecture to accomplish this.

Adding the routes configuration

First, we are gonna add some new config to our JSON database. Each client will have its own set of custom routes and the client side will render them accordingly.



[
  {
    "clientId": 1,
    "name": "Client A",
    "routes": {
      "home": {
        "path": "/",
        "component": "HomePage"
      },
      "product": {
        "path": "/product/:productId",
        "component": "ProductPage"
      }
    }
  },
  {
    "clientId": 2,
    "name": "Client B",
    "routes": {
      "home": {
        "path": "/",
        "component": "ProductPage"
      }
    }
  }
]


Enter fullscreen mode Exit fullscreen mode

So, we've added a new routes object, each of its nodes will be specific to a route.

Implementing React Router

We will need to install react-router-dom in our client-side so we can handle these routes. Open the terminal and go to the client folder and execute the following command:



npm i react-router-dom


Enter fullscreen mode Exit fullscreen mode

We are now gonna create a new component called Routes which will use react-router. This component will receive the routes config object we added to the database, will iterate through them and render their respective Route components.



import React from 'react';
import {
  BrowserRouter as Router,
  Switch,
  Route,
} from "react-router-dom";

function Routes({ routes }) {

  return (
    <Router>
      <Switch>
        {
          Object.keys(routes).map((key) => {
            const route = routes[key];
            return (
              <Route 
                key={`route-${route.path}`}
                path={route.path}
                exact>
                  <div>
                    {route.component}
                  </div>
              </Route>
            )
          })
        }
      </Switch>
    </Router>
  );
}

Routes.defaultProps = {
  routes: []
}

export default Routes;


Enter fullscreen mode Exit fullscreen mode

So, instead of hardcoding our Route components, we render them dynamically based on what we receive in the routes config.See that I've set the routes prop as an empty array in Routes.defaultProps so it doesn't crash if we don't receive any routes from the server. We'll use this later to define a set of default routes.

Another important thing to consider for now is that we are not rendering any actual component in these routes, just the component name so we can test this before moving on.

Now, let's go to the App component and implement the Routes component we just created. I've made some changes to the JSX and will now show the react logo while waiting for the config objects. If the request is successful our Routes component will receive the routes config and render the routes as expected.

The App component will now look something like this:



import React, { useState, useEffect } from 'react';
import logo from './logo.svg';
import './App.css';
import { getConfig } from './services/config.service';
import Routes from './Routes';

function App() {


  const [config, setConfig] = useState({ loading: true, data: {} });
  const { loading, data } = config;

  useEffect(() => {
    async function getConfigAsync(){
      const { data } = await getConfig();
      setConfig({ data });
    }

    getConfigAsync();
  }
  , []);

  return (
    <div className="App">
      <header className="App-header">
          {
            loading && <img src={logo} className="App-logo" alt="logo" />
          }
          {
            data.error && <p>'Error getting config from server'</p>
          }

          <Routes routes={data.routes}/>
      </header>
    </div>
  );
}

export default App;


Enter fullscreen mode Exit fullscreen mode

Ok, let's go and run the server and two client-side instances so we can test the two different configurations. Client A should show "HomePage" on the root route and Client B should show "ProductPage" for that same route.

In the project root folder let's do:



npm run server


Enter fullscreen mode Exit fullscreen mode

And then move to the client folder. Open two terminals here so you can run the two client instances:



REACT_APP_CLIENT_ID=1 npm start


Enter fullscreen mode Exit fullscreen mode

And:



REACT_APP_CLIENT_ID=2 npm start


Enter fullscreen mode Exit fullscreen mode

You should see the following screen for Client A :

drawing

And this one for Client B :

drawing

Rendering components dynamically

So, now that we are rendering the routes correctly we need to add an object in the client-side that will map a component name like HomePage and ProductPage to an actual component.

Let's add these two new components first. Create a components folder next to App.js and add the following code. We'll keep them simple for now:

HomePage.js



import React from 'react';

function HomePage() {

  return (
    <div>
      Welcome to the Home Page!
    </div>
  );
}

export default HomePage;


Enter fullscreen mode Exit fullscreen mode

ProductPage.js



import React from 'react';

function ProductPage() {

  return (
    <div>
      Welcome to the Product Page!
    </div>
  );
}

export default ProductPage;


Enter fullscreen mode Exit fullscreen mode

We now need to add an object that will map the component name we have in the config object with the real component. Right here in the components folder, add a componentMapper.js file with the following code:



import HomePage from './HomePage';
import ProductPage from './ProductPage';

const COMPONENTS = {
  'HomePage': HomePage,
  'ProductPage': ProductPage
}

export default COMPONENTS;


Enter fullscreen mode Exit fullscreen mode

We are now gonna use this mapper in our Routes component so each route renders its specific component.

Let's import the COMPONENTS map in the Routes and do some quick changes in the render function. We have to get the component from the map and then render it inside the Route component, like so:



import React from 'react';
import {
  BrowserRouter as Router,
  Switch,
  Route,
} from "react-router-dom";
import COMPONENTS from './components/componentMapper';

function Routes({ routes }) {

  return (
    <Router>
      <Switch>
        {
          Object.keys(routes).map((key) => {
            const route = routes[key];
            const Component = COMPONENTS[route.component];
            return (
              <Route 
                key={`route-${route.path}`}
                path={route.path}
                exact>
                  <Component />
              </Route>
            )
          })
        }
      </Switch>
    </Router>
  );
}

Routes.defaultProps = {
  routes: []
}

export default Routes;


Enter fullscreen mode Exit fullscreen mode

You should see the following screen for Client A :

drawing

And this one for Client B :

drawing

Default Routes

As the last step for today's post, we'll add support for default routes. This means that there will be routes that are common among clients. We'll have a set of default or common routes in our client-side so they don't have to be added for all the clients in their config objects.

We will need to add a DEFAULT_ROUTES object in our Routes component:



const DEFAULT_ROUTES = {
  about: {
    path: "/about",
    component: "AboutPage"
  },
}


Enter fullscreen mode Exit fullscreen mode

And set them as the default value for the routes prop in Routes.defaultProps :



Routes.defaultProps = {
  routes: DEFAULT_ROUTES
}


Enter fullscreen mode Exit fullscreen mode

But this is not enough if we want to include the about route along with the custom ones, we have to merge both objects, the custom one from the config and the default one. I'll also add a simple navigation menu so we can go to the About page. The Routes component will end up being something like this:



import React from 'react';
import {
  BrowserRouter as Router,
  Switch,
  Route,
  NavLink
} from "react-router-dom";
import COMPONENTS from './components/componentMapper';

const DEFAULT_ROUTES = {
  about: {
    path: "/about",
    component: "AboutPage"
  },
}

function Routes({ routes: customRoutes }) {

  // We'll now call the routes prop as customRoutes inside the component.
  // Merge customRoutes with the default ones.
  const routes = {...customRoutes, ...DEFAULT_ROUTES};

  return (
    <Router>
      <nav>
        <ul>
          <li>
            <NavLink to="/" activeClassName='active' exact>Home</NavLink>
          </li>
          <li>
            <NavLink to="/about" activeClassName='active' exact>About</NavLink>
          </li>
        </ul>
      </nav>
      <Switch>
        {
          Object.keys(routes).map((key) => {
            const route = routes[key];
            const Component = COMPONENTS[route.component];
            return (
              <Route 
                key={`route-${route.path}`}
                exact
                path={route.path}>
                  <Component />
              </Route>
            )
          })
        }
      </Switch>
    </Router>
  );
}

Routes.defaultProps = {
  routes: DEFAULT_ROUTES
}

export default Routes;


Enter fullscreen mode Exit fullscreen mode

I added the following styles to index.css so the Nav Bar would look OK:



.nav-bar {
  width: 100%;
  position: fixed;
  top: 0;
}

.nav-bar ul {
  list-style-type: none;
  margin: 0;
  padding: 15px;
  display: flex;
  justify-content: flex-end;
}

.nav-bar ul li {
  margin: 10px;
}

.nav-bar ul li a{
  text-decoration: none;
  color: white;
}

.nav-bar ul li a.active{
  color: cornflowerblue;
}


Enter fullscreen mode Exit fullscreen mode

Cool! So, now you should be able to navigate between routes and the About page will be available for both clients.If we wanted to show custom information for each client in the About page we would need to fetch that from the server but we'll leave it as is for now. Remember that for client B the Home route shows the ProductPage instead of the HomePage component.


That's all for now! We've now covered custom routes but we still have to cover customizable components based on the config. I'll leave that for the next post.

Here's the Github repo in case you want the whole project.

Stay tuned and thanks for reading!

Follow me on twitter: @jdelvx

Top comments (0)