Skip to content

Building a flexible frontend

How a multi-tenant frontend became flexible enough to serve different brands

As I described in the previous post, I built a BBCode parser to support a flexible frontend. Colors, text, icons, and images all had to be dynamic because each of them was controlled by the application's tenant.

The sections below explain the technical decisions behind that flexibility. Each heading is based on a real requirement that appeared while we were designing and building the frontend.

"I need a site that changes according to the brand"

This is where the complexity began. The team had already chosen most of the stack, and some components were already in place. The rest of the architecture was taking shape as well. Here is the stack:

  • React - SPA (Single Page Application)
  • Typescript
  • Redux + Redux Saga
  • Ant Design (an auxiliary library for components that would take too long to build under a short deadline)
  • Tachyons CSS
  • RC Components (a component library that still allowed us to customise the visual design)
  • moment, because Ant Design depended on it, although I wanted to use date-fns
  • Axios for HTTP requests
  • react-text-mask for masks such as CPF, CNPJ, phone numbers, and ZIP codes
  • A currency input built from scratch, inspired by a similar component in the NuBank mobile app

Another important, and potentially risky, decision was adopting React Hooks early. Alpha releases can introduce problems later, but using Hooks from the beginning gave us time to understand them and build custom hooks that shared logic across the application, including the dynamic routing strategy.

"I need a site that changes according to the brand. When the user accesses the domain xpto.com, they'll see this site in black. When they access the site abcd.dev, they'll see the site in purple. One thing I wanted to be possible is when opening the source code not having the possibility to see data from another site, even though the code is the same"

That gives you a picture of the stack. Because we used Ant Design, I also had to adapt its CSS to our configuration model so that the visual system could be dynamic. That led to the first problem.

  1. The first challenge was defining CSS variables at runtime. CSS supports variables through the var() function (MDN reference), and defining them in a stylesheet is simple. However, the stylesheet could reach 39,000 lines, so maintaining a separate file for each theme was not practical. The solution was the :root selector, which targets the root HTML element. By querying :root and setting properties on it, we could apply the theme variables at runtime:
import config from "./config-site";
const root: any = document.querySelector(":root");
Object.keys(config).forEach((x: string) => root.style.setProperty(`--${x}`, `${config[x]}`));

That solved the variable problem. We had a static configuration file defining the frontend variables, and the rest of the application could consume them without further changes.

  1. We had color-agnostic CSS that understood the variables in the configuration object, but we still needed a dynamic configuration file. How could the application load the correct configuration for each domain or subdomain without exposing another tenant's data? This could not be solved in the frontend alone, or in a single place. We combined an existing team practice with a CLI tool for theme management. If you read the previous post, you may remember the UI router; if not, it provides useful context for what follows.

The UI router is an F# web server that listens for requests to registered domains (in this case, xpto.com and abcd.dev). When it receives a request for xpto.com, it finds the corresponding files in an Amazon S3 bucket — the JavaScript, CSS, and other assets generated by the React build — and assembles a customised index.html. It injects a few values into that HTML:

  • Tenant
  • Version
  • Assets URL

Because the router assembles an .html file, it can inject JavaScript and choose which files to serve. Both are possible, but we found an even simpler way to avoid tenant-specific if statements.

Customise the React build to generate a folder for each tenant. The URL then tells the UI router which file to load.

This was simple, practical, and efficient. It did mean maintaining one configuration file per tenant, which was not ideal, but separate .json files were still easier to maintain than a large amount of conditional code. Multiple files can drift apart — one may receive an addition while another misses the corresponding change — but that is a familiar team problem. Git helps, although in this case every edit could still produce a conflict. The reason will become clear below. First, here is the configuration file:

{
    "tenant": "Xpto Industries ModaFoca",
    "colors": {
        "primary": "#000000",
        "info": "#00f"
    },
    "icon": "https://...",
    "logo": "https://...",
    "banner": "https://...",
    "text": {
        "pt-BR": {
            "siteTitle": "Hack the planet",
            "siteFooter": "Hack the planet",
        },
        "en-US":{
            "siteTitle": "Hack the planet",
            "siteFooter": "Hack the planet",
        }
    }
}

The real file is much larger because it contains more colors, text, and images. This excerpt is enough to show the structure. The next question is: "How will a browser read a JSON file and turn it into JavaScript?" The answer is it will not. That was the purpose of script00: transform JSON into a JavaScript object. The conversion itself is simple — create a .js file and assign the object to a variable — but the script also handled rules such as converting http to https, replacing tenant names based on the filename, and generating color variations. The important idea is this: create a script that generates JavaScript from a directory of JSON files.

const FS = require("fs");
const PATH = require("path");
const signale = require("signale");
const { transparentize, lighten, darken } = require("polished");

const dirname = `${__dirname}/../config/`;

const alpha = (color, name) => ({
  [`${name}Alpha`]: transparentize(0.5, color)
});
const light = (color, name) => ({ [`${name}Light`]: lighten(0.2, color) });
const lightest = (color, name) => ({
  [`${name}Lightest`]: lighten(0.6, color)
});
const dark = (color, name) => ({ [`${name}Dark`]: darken(0.2, color) });
const darkest = (color, name) => ({ [`${name}Darkest`]: darken(0.6, color) });

const colorize = (theme) => (acc, x) => {
  const c = theme[x];
  if (!`${c}`.startsWith("#")) {
    return acc;
  }
  return {
    ...acc,
    [x]: c,
    ...alpha(c, x),
    ...light(c, x),
    ...dark(c, x),
    ...darkest(c, x),
    ...lightest(c, x)
  };
};

const manifestJsonGenerator = (json, colors) => {
  return {
    short_name: json.tenant,
    name: json.tenant,
    icons: [
      {
        src: json.icon,
        sizes: "64x64 32x32 24x24 16x16",
        type: "image/x-icon"
      },
      {
        src: json.icon,
        sizes: "512x512",
        type: "image/x-icon"
      }
    ],
    start_url: ".",
    orientation: "natural",
    display: "standalone",
    theme_color: colors.primary,
    background_color: "#000"
  };
};

const replaceTenantName = (json, language) => {
  return JSON.stringify(json.lang[language])
    .replace(/XPTO/gi, json.tenant)
    .replace(/ABCD/gi, json.tenant)
    .replace(/XYZ/gi, json.tenant);
};

const createConfigFile = (contents, filename, referenceObject) => {
  const json = JSON.parse(contents);
  const { theme } = json;
  const tenant = filename.replace(/.json$/, "");
  signale.start(`Generate ${tenant} theme`);
  const colors = Object.keys(theme).reduce(colorize(theme), {});
  const ptBrTexts = JSON.parse(json.text, "pt-BR");
  const enUSTexts = JSON.parse(json.text, "pt-BR");
  return {
    ...JSON.parse(contents),
    theme: colors,
    texts: {
      ...json.texts,
      "pt-br": JSON.parse(ptBrTexts),
      "en-us": JSON.parse(enUSTexts)
    }
  };
};

const createContent = async (path, filename, referenceObject) => {
  if (filename !== "reference.json") {
    FS.readFile(`${path}${filename}`, "utf8", (err, contents) => {
      const json = JSON.parse(contents);
      const { theme } = json;
      const tenant = filename.replace(/.json$/, "");
      const prefixBuild = PATH.join(__dirname, "..", "build");
      const themeJS = PATH.join(prefixBuild, "js", `${tenant}.js`);
      const colors = Object.keys(theme).reduce(colorize(theme), {});
      const fullFile = createConfigFile(contents, filename, referenceObject);
      writeBpConfigFile(themeJS, fullFile);
      const folderName = PATH.join(prefixBuild, tenant);
      const manifestJson = PATH.join(folderName, "manifest.json");
      if (!!json.tenant) {
        FS.mkdir(folderName, () => {
          const manifest = manifestJsonGenerator(json, colors);
          FS.writeFile(manifestJson, JSON.stringify(manifest, null, 4), "utf-8", (err) => {
            signale.success(`Manifest.json for tenant: ${tenant}`);
          });
        });
      }
    });
  }
};

const prefixVar = "window.$___VARIABLE_WITH_IMPOSSIBLE_TO_COPY_NAME___.config";

const writeJsVarInFile = (path, fullFile, format = false) => {
  if (format) {
    return FS.writeFileSync(path, `${prefixVar}=${JSON.stringify(fullFile, null, 4)}`);
  }
  return FS.writeFileSync(path, `${prefixVar}=${JSON.stringify(fullFile)}`);
};
const REFERENCE_FILE = PATH.join(dirname, "..", "config", "reference.json");
const referenceObject = FS.readFileSync(REFERENCE_FILE, { encoding: "utf-8" });
const createFiles = async () => {
  FS.readdir(dirname, (_, files) => {
    files.forEach(async (file) => {
      await createContent(dirname, file, JSON.parse(referenceObject));
    });
  });
};
module.exports = {
  REFERENCE_FILE,
  referenceObject,
  writeJsVarInFile
};

That is a lot of code, but it shows the complete flow. I changed some details to avoid exposing proprietary information. The build folder is the same one generated by React. To integrate this process with the React build, the simplest option was to use eject and take control of the webpack configuration and build scripts. This script ran at the end of scripts/build.js, the file responsible for building the frontend. Before moving to the third problem, there is one reasonable question:

Wouldn't it have been easier to use webpack plugins to generate these files? Yes. In software development, convenience often comes with a trade-off. If you need this level of flexibility, you may have to work closer to the build system.

  1. The final problem was keeping configuration files in sync when text or other content changed. To solve it, I wrote another script that ran whenever I needed to add text to the site.
const { REFERENCE_FILE, writeJsVarInfile, referenceObject } = require("./frontend-builder");
const FS = require("fs");
const PATH = require("path");
const signale = require("signale");
const [shell, file, key, text, language = "pt-br"] = process.argv;
const CONFIGS_DIR = PATH.join(__dirname, "..", "config");
if (!!!key || !!!text) {
  signale.fatal("Provide the key and the text to be inserted");
  process.exit(1);
}
FS.readdir(CONFIGS_DIR, (_, files) => {
  files.forEach(async (file) => {
    const pathToFile = PATH.join(CONFIGS_DIR, file);
    const jsonString = FS.readFileSync(pathToFile, { encoding: "utf-8" });
    const json = JSON.parse(jsonString);
    signale.info(`Write: ${key} with value ${text}`);
    const fileContent = JSON.stringify(
      {
        ...json,
        texts: {
          "pt-br": {
            ...json.texts["pt-br"],
            [key]: text
          }
        }
      },
      null,
      4
    );
    FS.writeFileSync(pathToFile, fileContent);
    signale.complete("DONE");
    if (file === "reference.json") {
      const path = PATH.join(__dirname, "..", "public", "PLACEHOLDER.js");
      try {
        const configuration = createConfigFile(fileContent, path, JSON.parse(referenceObject));
        signale.success("Creating placeholder configuration file", path);
      } catch (error) {
        signale.fatal(error);
      }
    }
  });
});

This is another long code sample. You may notice errors because I removed lines containing information that cannot be published. The if (file === "reference.json") block creates a development file that acts as a skeleton, since the UI router supplies the complete configuration through HTML. It is a hack, or workaround, that lets the project run without errors during development.

"I need this text to be bold and that button to send a message through the store's WhatsApp"

This was the most challenging requirement. The text configuration was already complete and all strings were defined. Supporting rich formatting meant deciding at runtime which parts of a string should be bold and which should become links. We initially resisted the change, but eventually there was no way around it.

The first idea was to use a Markdown parser. I found several good options, but none solved the WhatsApp-specific requirement. That led to a familiar JavaScript decision: when no library does exactly what you need, build one from scratch with zero dependencies. It sounds like a cliché, but after researching the available options, no existing solution met the requirements.

I have liked parsers since I started programming. One of my first personal projects was a BBCode-to-HTML parser written in shell script. If you enjoy programming, try something similar as a way to learn regular expressions, parsers, yacc, and related topics. BBCode is not a better format than Markdown for non-technical users, but I already had experience with it. After some experimentation — and more than a little energy drink — I wrote this JavaScript version. The code is public and will be updated in mid-September. It is not the most elegant project, but it solved the problem and could also generate the WhatsApp link.

This code-markup-parser generates HTML, and how do I interpret raw HTML in React?

<span dangerouslySetInnerHTML={{ __html: codeMarkupParser(parsed) }} />

One important security measure was sanitizing all input HTML. Potentially malicious HTML and JavaScript are removed from parsed strings before they are rendered.

The remaining step was to create a method that reads strings from the configuration object and turns them into HTML. That lets the configuration express bold text as [b]This is bold on my site[/b].

const remapTexts = (map: any) => (acc: string, x: string) => acc.replace(new RegExp(RE(x), "gi"), map[trueTrim(x)]);

const parseWithParams = (resolvedValue: string, textParams: any) =>
	Object.keys(textParams).reduce(remapTexts(textParams), resolvedValue || "");

export function resolve({ text, textParams = {} }: ResolverType) {
	const map = selectLanguage();
	const resolvedValue = map[text] as any;
	if (Array.isArray(resolvedValue)) {
		return resolvedValue;
	}
	if (isEmpty(textParams)) {
		return <span dangerouslySetInnerHTML={{ __html: BbCode(resolvedValue) }}/>
	}
	return <span dangerouslySetInnerHTML={{ __html: BbCode(parseWithParams(resolvedValue, textParams)) }}/>
}

The strings also needed to accept variables. The remapTexts and parseWithParams functions handle that case. Text that needs both formatting and interpolation uses this syntax: [b]This text is bold[/b] and this text uses a variable {{ varName }}. The syntax is loosely reminiscent of template strings in Rails and Laravel. In JSX, it looks like this:

<p>
  {resolve({
    text: "stringThatExistsInTheTranslationMap",
    textParams: {
      varName: props.redux.aReduxValue
    }
  })}
</p>

Once this was finished, the configuration was much easier to maintain. Design could edit the visual content and marketing could write the text without requiring a large code change for every update.

The full request was longer:

"This link should not appear when the user does not have enough products. It should redirect them to the page shown when they have no products. Also make sure that, when there are no products, a menu item offering a new product is always visible. I noticed that after cancelling a product, the menu stayed visible until the page was reloaded. That does not look right."

At first, this may not sound too difficult. In practice, it involves dynamic route and menu control. Routes and menus are closely related, but React applications often build them separately — especially when the navigation changes with the profile of the logged-in user.

I solved this quickly because I had already considered the problem. I had postponed it while working on other components, refactoring code, and addressing security concerns. I also have to admit that UX is not my strongest area.

As mentioned earlier, routes and menus are closely related, so I grouped them in a single array and filtered that array according to the user's profile.

  1. Create a list of objects containing the component, menu icon, menu and page titles, and the profile allowed to view the route.
  2. List every dependency required to evaluate the route (the word "dependency" probably makes you think of a useEffect).
  3. Keep each route's validation logic isolated and add it to the object from step 1.
  4. Configure React Router to generate <Route /> elements from the array instead of declaring them all by hand.
  5. Filter the array using the information from steps 1, 2, and 3.

The implementation looked like this:

import resolve from "@/config/texts";
import HomeClient from "@/pages/HomeClient";
import useConnect from "@/hooks/state-manager/useConnect";
import { GlobalState } from "@/reducer";
import { useEffect, useState } from "react";
import { isEmpty } from "sidekicker/lib/comparable";
import { MdHome, MdAccountCircle, MdCreditCard, MdViewList, MdTransform } from "react-icons/md";
import { IconType } from "react-icons";

export type ClientRoute = {
	icon: IconType;
	title: string;
	route: string;
	useAuth: boolean;
	component: () => React.ReactElement;
	validate: (products: Product[]) => boolean;
};

const configRoutes: ClientRoute[] = [
	{
		icon: MdHome,
		title: resolve({text: "homePage"}),
        // by habit, I like to separate all
        // application links into an object, so I don't
        // repeat strings
        route: Links.client.home,
		component: HomeClient,
		useAuth: true,
		validate: (products: Product[]) => logicToEnable(products) && other(products)
    },
    // ...
];
const mapStateToProps = (_: GlobalState) => ({ products: _.ProductReducer.products });
const useClientRoutes = () => {
    const [routes, setRoutes] = useState([] as ClientRoute[]);
    // This useConnect was a custom hook that I'll make available in the future
    // it's basically the same as the connect component from react-redux
    // but without needing to make a wrapper and returns the correct types too
	const props = useConnect(mapStateToProps, {});
	const hasActiveCard = !isEmpty(ProductService.hasActiveItem(props.cards));
	useEffect(() => {
		const newRoutes = configRoutes.filter((x) => x.validate(props.products));
		setRoutes(newRoutes);
	}, [props.products]);
	return routes;
};

export default useClientRoutes;

This was not especially complex, but it removed duplicated rules. The same array drives both React Router and the navigation bars. Changing the hook changes both systems and applies the same validation rule to each. When the Redux products value updates, the router and navigation immediately use the new set of rules.

The extra flexibility affected performance, though: bundle.js was approaching 600 KB. Because of the UI router's structure, standard code splitting was not working. The asset path differed from the domain used to access the application, so Suspense and lazy could not resolve the chunks correctly.

That problem still needed a solution, and it became the main reason I wrote this deeper account of how the UI was built.

"The site is very slow to open. We need to fix this urgently."

The performance problem was largely a result of the non-functional requirements accumulated throughout the project. I accepted the challenge, although I was unsure of the outcome: three months of researching code splitting for this architecture had produced no useful lead.

Once I focused exclusively on this problem, a solution appeared in about three hours. I had spent another five hours sketching ideas and testing them, and considered these approaches:

  1. Create a proxy in the UI router that redirects each webpack chunk pattern to the correct tenant's S3 bucket. This would be expensive and inefficient.

  2. Create a script that forces tenant URLs and changes the UI version to v0.0.0-tenant-name. A build-time .sh script would then replace the chunk patterns with the S3 bucket URL for each tenant. This was a significant workaround with long-term maintenance costs, but I considered it seriously.

  3. Study webpack in depth.

Option 3 was the right path.

I have always disliked working with webpack, and modifying the webpack configuration generated by CRA is even less appealing. I knew webpack was powerful, but I had not expected it to solve this problem so cleanly.

The nearly 600 KB bundle.js became several chunks of at most 10 KB. The largest, containing the Redux action files and business rules, was 120 KB. That was a substantial improvement — not magic, but the result of code splitting with React's Suspense and lazy APIs.

The key was in the webpack documentation, specifically the guide to public-path. I had searched for __webpack_public_path__ throughout bundle.js, build.js, start.js, and the rest of the application runtime, but it was nowhere to be found. I assumed it was an internal option. CRA already configures PUBLIC_PATH, so I initially thought that was the right abstraction, but changing it did not solve the problem. Then I found this issue explaining the difference between PUBLIC_PATH and __webpack_public_path__. That clarified the solution:

// Sets the webpack on the fly at runtime (hence on the fly)
/// <reference path="./definitions/definitions.d.ts" />
declare let __webpack_public_path__: string;
__webpack_public_path__ = `https://buckets.amazon/${$__OBJECT__.tenant}/sites/${$__OBJECT__.version}/`;

The problem was solved. A small runtime change fixed an issue that had remained open for more than three months.

The Lighthouse performance score also rose from 3 — on a slow connection and a low-powered phone — to 92 under the same conditions.

This is my detailed account of a real project and the trade-offs behind its frontend architecture. One question may still remain:

"But Allan, isn't this a hack? Using window as a global variable for the application to consume."

I had the same concern at first. But JavaScript applications sometimes need unconventional solutions, even with TypeScript, strict linting, tests, and other engineering practices. The history of JavaScript provides useful context: performance constraints and state shared across boundaries often lead to patterns like this.

A useful reference point is: "If Facebook controls its React version by appending to the window object, why can't a UI be configured the same way?"

The problem is not choosing an unconventional implementation. If it satisfies the business requirements, the team understands and accepts the trade-offs, and maintenance remains manageable, it is a sound engineering decision.