Module Federation with Tailwind CSS and Rspack

The Problem

We had a large project that needed to be split into multiple micro-frontends. We chose Module Federation to share modules across the application and Tailwind CSS for styling (v3). However, we encountered a significant challenge.

Module Federation Architecture

Our sample architecture consists of two federated remotes: Host and Common. The Host is the main React application with Tailwind CSS, while Common is a shared library, imported as a federated module used by both Host and Remote applications. Both use Tailwind CSS with a shared configuration.

Here’s the issue: Components in Host compile with the host’s styles.css that includes all Tailwind imports, so Tailwind classes compile properly. However, components imported from Common don’t compile their Tailwind classes because they’re exported as separate units without access to the common styles.css file. This means they lack the @tailwind imports needed for class compilation.

The Solution

Step 1: Inject Tailwind Styles for Each Export

Instead of manually importing @tailwind styles in every component (which would be cumbersome), we can automate this with a custom loader in our webpack/rspack configuration.

css
/* tailwind.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
javascript
/* injectTailwind.js */
module.exports = function (source) {
if (source.startsWith('import')) {
return `import "./tailwind.css";\n${source}`;
}
return source;
};
javascript
/* rspack.config.js */
// exposes is the map of modules exported in the module federation config
const filesToInjectTailwind = Object.values(exposes).map(file =>
path.resolve(dirname, file),
);
module.exports = {
// ... existing config
module: {
rules: [
{
test: /\.(ts|tsx|js|jsx)$/,
include: filesToInjectTailwind,
exclude: /node_modules/,
use: [
{
loader: path.resolve(__dirname, './injectTailwind.js'),
options: { name: moduleName },
},
],
}
]
}
};

Step 2: The Specificity Problem

This creates a new issue: CSS specificity conflicts. Since Host and Common compile Tailwind separately, they don’t know about each other, leading to specificity problems.

For example, if you use text-sm in Host (applying font-size: 14px) and text-lg on a <Button /> component from Common (applying font-size: 18px) on the same element, the Common styles will override the Host styles because Common’s styles.css loads after Host’s.

Available Solutions

  1. Tailwind important option: Scope all Tailwind classes to a parent class or ID

    • This didn’t work for us since we export atomic components and having one parent isn’t viable
  2. Tailwind prefixes: Add prefixes to each Tailwind class (e.g., host-text-sm, common-text-lg)

    • This isolates classes but is extremely challenging to implement at scale, especially for existing codebases. It also breaks Tailwind’s auto-complete functionality
  3. The Magic Bullet: A hybrid approach that’s much more practical

The Magic Bullet

We combine the best of both approaches by using data attributes as CSS selectors.

The Concept

We attach a data attribute to each element that uses Tailwind classes. All Host-rendered HTML tags get a host-remote data attribute, and all Common-rendered HTML tags get a common-remote data attribute.

html
<!-- host.module.html -->
<div host-remote class="text-sm">Host says Hello</div>
css
/* host styles.css */
[host-remote].text-sm {
font-size: 14px;
}
html
<!-- common.button.html -->
<button common-remote class="text-lg">Click Me</button>
css
/* common styles.css */
[common-remote].text-lg {
font-size: 18px;
}

Implementation

javascript
/* injectDataAttribute.js */
function injectDataAttributeJSX(source, attrValue) {
return source.replace(/className=/g, `${attrValue}-mfe="" className=`);
}
module.exports = function (source) {
if (source.startsWith('import')) {
const { name } = this.getOptions();
return injectDataAttributeJSX(source, name);
}
return source;
};
javascript
/* rspack.config.js */
module.exports = {
module: {
rules: [
{
test: /\.(ts|tsx|js|jsx)$/,
exclude: /node_modules/,
use: [
{
loader: path.resolve(__dirname, './injectDataAttribute.js'),
options: { name: moduleName }, // moduleName is 'host' or 'common'
},
],
}
]
}
};
javascript
// postcss.config.js
module.exports = {
plugins: {
tailwindcss: tailwindConfig,
autoprefixer: {},
'postcss-prefix-selector': {
prefix: `[${moduleName}-mfe]`,
skipGlobalSelectors: true,
transform(prefix, selector, _prefixedSelector, _filePath, rule) {
if (rule.raws.tailwind && rule.raws.tailwind.layer !== 'base') {
return `${prefix}${selector}`;
}
return selector;
},
},
},
};

Final Challenge: CSS Duplication

Most projects use chunking for efficient loading, but this works against us here. If we have 20 components in the Common module that use the Tailwind class flex, we’ll get 20 CSS chunks with the same flex class. We need to combine them to avoid redundancy.

javascript
/* rspack.config.js */
module.exports = {
plugins: [
new rspack.CssExtractRspackPlugin({
filename: '[name].[hash].css',
}),
],
...
optimization: {
splitChunks: {
cacheGroups: {
styles: {
test: /\.css$/,
name: `${moduleName}-styles`,
chunks: 'all',
enforce: true,
},
},
},
},
};

Result

With this setup, you now have full Tailwind CSS v3 support working properly with Module Federation. Each federated module maintains its own CSS scope while sharing components seamlessly across your micro-frontend architecture.