White Label Web App With ReactJS and Webpack
On one of my previous projects, our goal was to have one application that can be used in-house to produce multiple, differently branded end-products with similar (or mostly the same) functionalities.
The solution needed to be able to:
- apply different styles/theme per brand
- cater for content differences
- cater for layout/structural and behaviour differences between brands — this may feel like a too big ask for a white label app but usually, it’s just a matter of time when the product folks will require that and having a way to enable this in a sensible way is a big win
- produce multiple products ideally without a need to make custom code replacements or custom code generation at build time
In this post, I’ll describe the approach we took to build multiple different versions of a React app from one codebase.
Research
Creating white-label applications with ReactJS is not a new topic. There are multiple approaches and blog posts out there describing solutions ranging from runtime theming, through build-time theming to writing custom app generators.
Our challenge appeared to be very similar to the one Walmart had. I found their blog post a very interesting and inspiring read. Even though their approach is more fitted to larger companies and bigger apps than ours, some of the underlying principles stay the same.
None of these examples was enough to suit our requirements though, so once I figured out a (yet another) way to do it, I decided to share it.
A solution
My solution was based on the Webpack’s resolve.modules
setting. It allowed us to have modules resolved at build time without providing a specific path to them in the imports. It also enabled us to have a default fallback location for modules that didn’t need to be customised for some brands.
#1 Base project
As the base for the working demo, I’m going to use one of the example React projects (the calculator) with removed react-scripts and combined with a minimalistic Webpack and Babel configuration for simplicity. With that, we get a project with the following structure as the starting point:
and when you run npm run build
you get an app that looks like that:
#2 Introduce resolve.modules
Let’s add the brands
folder:
and make the following changes to the code (the diff ignores the moved CSS files for brevity):
At this point what we get is exactly the same app just with a bit confusing folder structure ;) What happened is that we no longer use relative paths to include the CSS files and let Webpack resolve them as modules at build time. Let’s leverage that in the next step.
#3 Add more brands
Let’s add acme
and calc_co
as new brands and customise the buttons and display respectively for each.
We’ll also need to update the webpack.config.js
as follows:
The thing to note in this step is that neither acme
or calc_co
have a full set of files. Thanks to the default fallback mechanism, they don’t need to. We told Webpack to first look for modules for the brand defined in the APP_BRAND
environmental variable, then in the default fallback location and then, if it still didn’t find the module, it means it’s an external one so it will look for in the node_modules
.
Now we can build the app targeting specific brand with APP_BRAND={brand_name} npm run build
to get the following:
We can apply different styling to the brands. Cool, but that was just one of many requirements. Let’s get into some more interesting stuff next.
#4 Support structural & content differences
In order to further differentiate the brands, apart from the styling, we introduced, what we called, wrapper objects (yes, naming is hard ;)). Their purpose is to enable structural and content differences between brands while avoiding code duplication (or keeping it to a minimum). The idea is that the main business logic and rendering stay in the top-level components (not brand specific), but these components are parameterised via props so that it’s possible for them to look or behave differently depending on these parameters. We found it to be a clean way of keeping the brand-specific code reasonable in size and contained within the folder of the given brand.
Here’s an example:
Here’s how I parameterised the main components:
For the wrapper components to take effect you need to import them instead of the main components. Example from the index.js
:
I used the AppWrapper
to place the calculator display at the bottom for the Calc Co brand:
I used roman numbers for the Acme buttons:
I fished off with totally customised button layout for the Calc Co:
The end results are as below:
As you can see, using this approach, you can get significantly different apps with not much additional code. Once you have the main components parameterised, you can easily build apps with various combinations of them.
You can view the live end-products here: default, acme, calc_co.
Productionising the solution
IDE features
This kind of custom module resolution may confuse some IDEs as they might not be aware of the location of the branded modules.
Here’s how to give a hint to VS Code intellisense via jsconfig.json as an example:
With jsconfig there’s no option to make it dynamic and make it handle other brands, unfortunately, but even having VS Code to look into the default one is a plus.
If you happen to use TypeScript, there’s a way to make it better though (see below).
Using TypeScript
If you use TypeScript, you can use the same compilerOptions
in your tsconfig.json
as with the above jsconfig.json
. See the TS docs for details.
Although there’s no official support for a tsconfig.js
, there’s an npm package which enables you to have dynamic TypeScript config which would suit this use case well as you’ll be able to reference the process.env.APP_BRAND
in it.
Using Babel
With the babel-plugin-module-resolver and using JavaScript configuration file, you can configure the custom brands paths as follows:
Using ESLint
You can make ESLint rely either on your Babel config (eslint-import-resolver-babel-module), TypeScript config (eslint-import-resolver-typescript) or Webpack config (eslint-import-resolver-webpack). There are options for any of your project setup ;)
Improved CSS handling
One could argue that it isn’t great that I copied whole CSS files in order to customise just a couple of colours. They would be correct. One way of improving that would be to use Sass and have shared files with the styles which would use Sass variables. These files could import the variables and these imports could rely on the same mechanism as described above. The only thing you would need to have brand-specific, in this case, would be the definitions of the variables.
Further improvements
One thing which is not great with the described solution is that, when you look at the imports themselves, it may be hard to know which import is an external module and which one is your own branded one.
The solution to this could be using the resolve.alias
setting instead of the described resolve.module
. The problem with resolve.alias
is that currently it only accepts string values, so there’s no way to achieve the default fallback behaviour.
The good news is that Webpack starting from version 5 (currently in beta) will support multiple values for the aliases as an array.
Another way of improving that could be using tsconfig-paths-webpack-plugin, if you use TypeScript, and, instead of specifying module resolution in webpack.config.js
, rely on paths defined in your tsconfig.json
as they support multiple values for aliases. I haven’t tried that one yet though.
Conclusion
So now you know how to leverage Webpack’s resolve.module
in order to have a white labelled React application and build multiple differently branded products from one codebase. I understand it’s just yet another way of achieving that but, hopefully, someone with similar requirements will find it useful.
The source code of the example project I created is available on GitHub. You can also view the live end-products here: default, acme, calc_co.