DEV Community

Ugur Aslim
Ugur Aslim

Posted on • Originally published at uguraslim.com

Tailwind CSS Just-In-Time Compilation in Monorepos: Why Your Build Times Explode With Shared Component

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
Enter fullscreen mode Exit fullscreen mode

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: [],
};
Enter fullscreen mode Exit fullscreen mode

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: [],
};
Enter fullscreen mode Exit fullscreen mode
// 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: [],
};
Enter fullscreen mode Exit fullscreen mode

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: [],
};
Enter fullscreen mode Exit fullscreen mode

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: [],
};
Enter fullscreen mode Exit fullscreen mode

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}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

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: [],
};
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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'],
  }
]
Enter fullscreen mode Exit fullscreen mode

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)/ },
]
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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)