Tailwind CSS Just-In-Time Compilation in Monorepos: Why Your Build Times Explode With Shared Components
I built CitizenApp as a monorepo with 6 internal packages—a core UI library, feature modules, and admin dashboards. Everything ran fine until we hit 50+ shared components. Then our Tailwind build went from 2 seconds to 45 seconds. That's not a typo. Forty-five seconds for a dev rebuild because the JIT compiler was re-scanning the entire monorepo on every change.
The problem isn't Tailwind. It's how you configure content paths in a monorepo. I've made every mistake here, and I'm going to show you exactly how to avoid them.
The Core Problem: Content Path Explosion
Tailwind's JIT compiler needs to know where your templates live. In a monorepo, you have content in multiple places:
packages/
ui-library/src/components/Button.tsx
features/dashboard/src/pages/index.tsx
admin/src/layouts/AdminLayout.tsx
The naive approach is what I did initially:
// tailwind.config.js - THE WRONG WAY
export default {
content: [
'./src/**/*.{ts,tsx}',
'../ui-library/src/**/*.{ts,tsx}',
'../features/*/src/**/*.{ts,tsx}',
'../admin/src/**/*.{ts,tsx}',
],
theme: { extend: {} },
plugins: [],
};
This works, but Tailwind watches every file in every package. When you edit a button in ui-library, the compiler re-scans all 6 packages. When you edit a dashboard page, same thing. Quadratic growth.
I watched the file watcher logs. On a single keystroke in one component, Tailwind was scanning 47,000 files. That's why the build took 45 seconds.
Solution 1: Use Package-Level Config Files
Instead of one global config scanning everything, give each package its own Tailwind config that only knows about itself:
// packages/ui-library/tailwind.config.js
export default {
content: ['./src/**/*.{ts,tsx}'],
theme: { extend: {} },
plugins: [],
};
// packages/features/dashboard/tailwind.config.js
export default {
content: [
'./src/**/*.{ts,tsx}',
// ONLY reference what this package needs
'../ui-library/src/**/*.{ts,tsx}',
],
theme: { extend: {} },
plugins: [],
};
But here's the catch: you still need a root config for the main app. The trick is making it lazy—only include packages that actually get bundled into your final output.
// apps/web/tailwind.config.js - THE RIGHT WAY
export default {
content: [
'./src/**/*.{ts,tsx}',
// Only include packages we ACTUALLY import
'../../packages/ui-library/src/**/*.{ts,tsx}',
'../../packages/auth/src/**/*.{ts,tsx}',
// DO NOT use glob patterns like ../../packages/*/src/**
// That scans everything, even packages you don't use
],
theme: { extend: {} },
plugins: [],
};
Why explicit is better: Explicit paths force you to declare dependencies. You can't accidentally slow down your build by adding a new package you don't use.
After this change, our build time dropped to 8 seconds. Still not 5, but getting there.
Solution 2: Pre-Build and Export CSS Classes
The real performance win is not relying on JIT scanning for shared components. Pre-build them.
For ui-library, I create a static CSS output that includes all component utilities:
// packages/ui-library/tailwind.config.js
export default {
content: [
'./src/**/*.{ts,tsx}',
// Add a safelist for all component variants we support
{
pattern: /Button--(primary|secondary|danger|ghost)/,
variants: ['hover', 'focus', 'disabled'],
},
],
theme: {
extend: {
colors: {
primary: '#0066FF',
secondary: '#666666',
},
},
},
plugins: [],
};
The safelist tells Tailwind: "Always include these classes, even if you don't find them in the content scan." This prevents JIT from purging utilities that exist as dynamic strings:
// packages/ui-library/src/components/Button.tsx
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
}
export function Button({ variant = 'primary', ...props }: ButtonProps) {
// Dynamic class names need safelist!
const variantClass = `Button--${variant}`;
const baseStyles = 'px-4 py-2 rounded font-semibold transition';
const variantStyles: Record<string, string> = {
primary: 'bg-primary text-white hover:bg-blue-700',
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
danger: 'bg-red-600 text-white hover:bg-red-700',
ghost: 'bg-transparent border border-gray-300 hover:bg-gray-100',
};
return (
<button
className={`${baseStyles} ${variantStyles[variant]}`}
{...props}
/>
);
}
Then in the root app, reference the pre-built output:
// apps/web/tailwind.config.js
export default {
content: [
'./src/**/*.{ts,tsx}',
// Pre-built components are already styled, don't re-scan
'../../packages/ui-library/dist/**/*.js',
],
theme: { extend: {} },
plugins: [],
};
This dropped us to 4.2 seconds.
Solution 3: Watch Only Changed Packages
The last trick is smarter file watching. Use Tailwind's watch mode with a filter:
{
"scripts": {
"dev": "turbo run dev --filter=@app/web",
"build": "turbo run build"
}
}
With Turbo (or Nx), only changed packages trigger rebuilds. Tailwind only watches files in packages that are actually running.
Gotcha: Safelist Bloat
Here's what burned me: I was too aggressive with safelists.
// WRONG - This defeats purging
safelist: [
{
pattern: /./, // matches everything!
variants: ['hover', 'focus', 'active'],
}
]
Safelist disables purging for matching patterns. If your pattern is too broad, your CSS bundle explodes. I accidentally included a 340KB stylesheet because I safelisted all utilities with "text-" in them.
The fix: only safelist truly dynamic classes you can't predict:
// RIGHT - Specific and minimal
safelist: [
// Only component variants that are computed at runtime
{ pattern: /^(Button|Input|Card)--/ },
// Only colors from your theme that come from API data
{ pattern: /^(text|bg)-(primary|secondary|danger)/ },
]
What I Missed
I initially thought the problem was Tailwind itself being slow. It's not. Tailwind's JIT is incredibly fast—the problem is content path misconfiguration making it scan way more than it should.
Also, I didn't realize that CSS-in-JS libraries (Emotion, styled-components) in shared components break content scanning entirely. If your UI library uses CSS-in-JS, Tailwind can't find the utilities and will purge them. You have to use safelist or pre-built CSS.
The Final Config
Here's what works for CitizenApp's 6-package setup with sub-5-second builds:
// apps/web/tailwind.config.ts
import type { Config } from 'tailwindcss';
export default {
content: [
'./src/**/*.{ts,tsx}',
'../../packages/ui-library/src/**/*.{ts,tsx}',
'../../packages/auth/src/**/*.{ts,tsx}',
],
theme: {
extend: {
colors: { primary: '#0066FF' },
},
},
safelist: [
{ pattern: /^(button|input|card)--/ },
{ pattern: /^(text|bg)-\w+/ },
],
} satisfies Config;
Build time: 4.8 seconds on 5 packages with 200+ components.
The key insight: make Tailwind's job smaller, not faster. Explicit content paths, package-level configs, and strategic safelists beat any optimization trick.
Top comments (0)