DEV Community

Cover image for Building a Multi-Tenant React App. Part 3: Customizable Components
José Del Valle
José Del Valle

Posted on • Originally published at delvalle.dev on

Building a Multi-Tenant React App. Part 3: Customizable Components

Hello again! I've been quite busy lately and haven't been able to keep up with this series. Let's try to go back to where we were and keep building this app. I think this one's gonna be short. In case you haven't read the previous articles here they are:

Today I'm gonna talk about customizable components and how you can render client-specific stuff in the UI based on configuration. We already know how to render different routes for the different clients but what about different components and components that are flexible enough to look and feel different?

So, starting where we left off in the previous article. Client A shows a HomePage component in the root route whereas Client B shows a ProductPage component. Let's say Client A wants to show a list of products in the HomePage via some cards. Client B will show a featured product in the ProductPage and some cards below it for the related products.

These cards will look different for these clients but we'll use the same component. This component will receive some config from the server that will tell it how to look.

Adding the Card configuration

We'll add a new components node in our config objects, right next to the routes node. We don't want to nest the component configurations inside the routes configuration cause the entire config object could get pretty deep and the components don't care in which route they are.

The card for Client A will have a header, an image, a footer, and will show a title and description. For Client B it will not show the header.

This is how our config objects will look now:

[
  {
    "clientId": 1,
    "name": "Client A",
    "routes": {
      "home": {
        "path": "/",
        "component": "HomePage"
      },
      "product": {
        "path": "/product/:productId",
        "component": "ProductPage"
      }
    },
    "components": {
      "card": {
        "showHeader": true,
        "showImage": true,
        "showFooter": true
      }
    }
  },
  {
    "clientId": 2,
    "name": "Client B",
    "routes": {
      "home": {
        "path": "/",
        "component": "ProductPage"
      }
    },
    "components": {
      "card": {
        "showHeader": false,
        "showImage": true,
        "showFooter": true
      }
    }
  }
]

Creating the Config Context

So now we'll have components that will receive their configuration. It would be useful to use React's Context API so we can have our entire client configuration in one single place. It can then be accessed by any component that needs it.

We'll create a new folder inside src called context and will add a new file called Config.js with the following code:

import { createContext } from 'react';

const initialConfig = {
  name: "No name",
  routes: {},
  components: {}
};

const ConfigContext = createContext(initialConfig);

export default ConfigContext;

What we are doing here is creating a new empty context that will store our config object. We'll leave those values empty for now but this is the place where you would want to add default configuration properties in case they are missing in the config that comes from the backend.

Now in App.js we need to import this context. We also need to wrap the Routes component in a Config Context Provider, like so:

The value prop in ConfigContext.Provider will receive the config we got from the server. This config will now be accessible to any component down the tree that makes use of useContext to access...well...the config context.

The entire App.js file will now look 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';
import ConfigContext from './context/Config';

function App() {

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

  const { loading, data } = config;

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

    getConfigAsync();
  }
  , []);

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

export default App;

Implementing the Card Component

Now that we have our Config Context ready, we just have to start consuming it in our components. The Card component will use useContext to get access to it and will retrieve the card-specific configuration. It will render according to the variables in that configuration.

import React, { useContext } from 'react';
import ConfigContext from '../context/Config';

function Card({ title, description, img }) {

  const { components: { card }} = useContext(ConfigContext);
  const { showHeader, showImage, showFooter } = card;

  return (
    <div className="card-container">
      {
        showHeader && (
          <div className="card-header">
            <h4 className="card-title">
              {title}
            </h4>
          </div>
        )
      }
      {
        showImage && (
          <img className={!showHeader ? "card-image-rd" : "card-image"} src={img} alt="Card Img" height="240" width="320"/>
        )
      }
      {
        showFooter && (
          <div className="card-footer">
            <p className="card-description">
              {description}
            </p>
          </div>
        )
      }
    </div>
  );
}

export default Card;

I'll add the card styles at the end of the article so we can keep going with what's important.

The Card component will show or hide the header, footer, and image based on the configuration it receives from the context.

You can see that the Card component is receiving a title, a description, and an image from props. We have to define these somewhere and we also need to make use of this component. We'll go to the HomePage and ProductPage and add it there.

We'll add an array of items in our HomePage component. We'll loop through this array and return a card for each item. It will end up looking like this:

import React from 'react';
import Card from './Card';

const items = [
  {
    id: 'card1',
    title: 'First Card',
    description: 'Some boring description',
    img: "https://loremflickr.com/320/240/food?random=1"
  },
  {
    id: 'card2',
    title: 'Second Card',
    description: 'Some boring description',
    img: "https://loremflickr.com/320/240/food?random=2"
  },
  {
    id: 'card3',
    title: 'Third Card',
    description: 'Some boring description',
    img: "https://loremflickr.com/320/240/food?random=3"
  }
]

function HomePage({ items }) {

  return (
    <div>
      Welcome to the Home Page!
      <div className="cards-container">
        {
          items.map((item) => (
            <Card key={item.id} {...item} />
          ))
        }
      </div>
    </div>
  );
}

HomePage.defaultProps = {
  items
}

export default HomePage;

Normally, we'll receive those items from the server but we keep this simple for now.

As for the ProductPage we'll do something a little different. Let's say Client B decided to show a featured product more predominantly and some cards below it for the other products.

For this, we're still gonna have an array of items but one of them will have a featured flag set to true. We are gonna render the featured item above the other ones. The component will look something like this:

import React from 'react';
import Card from './Card';

const items = [
  {
    id: 'card0',
    title: 'Featured Product',
    description: 'Interesting description',
    img: "https://loremflickr.com/320/240/food?random=0",
    featured: true
  },
  {
    id: 'card1',
    title: 'First Card',
    description: 'Some boring description',
    img: "https://loremflickr.com/320/240/food?random=1"
  },
  {
    id: 'card2',
    title: 'Second Card',
    description: 'Some boring description',
    img: "https://loremflickr.com/320/240/food?random=2"
  },
  {
    id: 'card3',
    title: 'Third Card',
    description: 'Some boring description',
    img: "https://loremflickr.com/320/240/food?random=3"
  }
]

function ProductPage({ items }) {

  const featuredProduct = items.find((item) => item.featured === true);
  const relatedItems = items.filter((item) => !item.featured)

  return (
    <div>
      Welcome to the Product Page!
      <div className="featured-product">
        <div>
          <img className="featured-img" src={featuredProduct.img} alt="Featured Img" height="240" width="320"/>
        </div>
        <div className="featured-content">
          <h2>{featuredProduct.title}</h2>
          <p>{featuredProduct.description}</p>
        </div>
      </div>
      <div className="cards-container">
        {
          relatedItems.map((item) => (
            <Card key={item.id} {...item} />
          ))
        }
      </div>
    </div>
  );
}

ProductPage.defaultProps = {
  items
}

export default ProductPage;

So what we are doing is extracting the featured item and the rest of the items into separate variables so we can render them separately.

Now, before running the app to verify how all this looks, let's add some styles in index.css :

.cards-container {
  display: flex;
  margin: 20px 0;
}

.card-container {
  width: 320px;
  margin: 5px;
  background-color: white;
  color: black;
  border-radius: 20px;
  box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);
}

.card-header, .card-footer {
  padding: 10px;
}

.card-title, .card-description {
  margin: 0;
}

.card-image-rd {
  border-top-left-radius: 20px;
  border-top-right-radius: 20px;
}

.card-description {
  font-size: 18px;
  text-align: left;
}

.featured-product {
  display: flex;
  margin-top: 20px;
  background-color: white;
  color: black;
  border-radius: 20px;
  height: 240px;
  box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);
}

.featured-img {
  border-top-left-radius: 20px;
  border-bottom-left-radius: 20px;
}

.featured-content {
  padding: 10px;
  text-align: left;
}

Run the app

Just like in the other articles. We have to run the server in one terminal and each of the clients separately using the following commands:

npm run server

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

And:

REACT_APP_CLIENT_ID=2 npm start

This is how Client A should look:

client-a

And this is how Client B should look:

client-b

And that's it! We now have a basic multi-tenant app that can render different routes and components based on configuration received from the backend. As I mentioned in the first article, the real world multi-tenancy project I worked in was a little bit more complex but the basic ideas and architecture remain.

This approach should be enough to work on a larger app and gives space to work in more customization. Any route and any component can be customizable following this approach. Remember, if you want to add default configuration you can do so where we created the Config Context in the initialConfig object. This way if there is a client that doesn't have any configuration coming from the backend you can rely on the defaults.

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

Stay tuned and thanks for reading!

Follow me on twitter: @jdelvx

Discussion (0)