Skip to main content
All posts

Tailwind CSS @apply Pitfalls in Monorepos: Why Your Component Library's Styles Break When Imported Into Astro and React Separately

6 June 2026

Tailwind CSS @apply Pitfalls in Monorepos: Why Your Component Library’s Styles Break When Imported Into Astro and React Separately

I spent three hours debugging missing button styles in our CitizenApp component library last month. The button worked perfectly in Storybook. It looked great in our React dashboard. But when we imported the same component into our Astro marketing site, the styles vanished completely in production.

The culprit? Tailwind’s @apply directive combined with how monorepo content scanning works. This isn’t documented well, and most solutions I found were band-aids. Here’s what actually happens—and how to fix it properly.

The Problem: Content Paths Don’t Cross Package Boundaries by Default

Tailwind uses a Just-In-Time (JIT) compiler that scans your template files for class names. When you add content paths to tailwind.config.ts, you’re telling Tailwind “look in these folders for class references.”

// packages/ui/tailwind.config.ts
export default {
  content: [
    './src/**/*.{ts,tsx}',
    './stories/**/*.{ts,tsx}',
  ],
  theme: {},
  plugins: [],
}

This works fine in isolation. But in a monorepo, your Astro site and React app each have their own Tailwind config. When Astro builds, it scans only Astro’s content paths. When React builds, it scans only React’s content paths.

Your shared component in packages/ui/src/Button.tsx gets imported into both. But:

This is why it works in development (Tailwind’s dev mode doesn’t purge) but breaks in production.

Why @apply Makes This Worse

@apply is the hidden culprit here. When you use @apply in a component:

// packages/ui/src/Button.tsx
import styles from './button.module.css';

export function Button({ children }: { children: React.ReactNode }) {
  return <button className={styles.button}>{children}</button>;
}
/* packages/ui/src/button.module.css */
.button {
  @apply px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors;
}

The problem: Tailwind scans your .tsx files for class names. It sees className="styles.button", which is a dynamic reference. It doesn’t actually see the string "px-4 py-2 bg-blue-600..." in your template. Those classes live in CSS, not in component code.

When your Astro build runs, Tailwind scans Astro’s templates and never sees Button being used with @apply classes inside a CSS file in another package. Result: those utilities get purged.

The Fix: Explicit Content Paths in Each Consumer

The real solution is making each workspace’s Tailwind config aware of shared packages. You need to tell Astro’s build to also scan the UI package:

// apps/astro-site/tailwind.config.ts
import type { Config } from 'tailwindcss';
import path from 'path';

export default {
  content: [
    './src/**/*.{astro,ts,tsx}',
    // Scan the shared UI package
    path.resolve(__dirname, '../../packages/ui/src/**/*.{ts,tsx}'),
    // If using CSS modules with @apply, ALSO include CSS files
    path.resolve(__dirname, '../../packages/ui/src/**/*.css'),
  ],
  theme: {},
  plugins: [],
} satisfies Config;

And for React:

// apps/react-dashboard/tailwind.config.ts
import type { Config } from 'tailwindcss';
import path from 'path';

export default {
  content: [
    './src/**/*.{ts,tsx}',
    path.resolve(__dirname, '../../packages/ui/src/**/*.{ts,tsx}'),
    path.resolve(__dirname, '../../packages/ui/src/**/*.css'),
  ],
  theme: {},
  plugins: [],
} satisfies Config;

Why this works: Now when Astro builds, it actually scans packages/ui/src/**/*.css and finds your @apply rules. Tailwind understands “this CSS file uses these utilities” and keeps them in the final build.

Better: Single Tailwind Config for Monorepos

I prefer eliminating duplication entirely. Use a shared Tailwind config in your UI package:

// packages/ui/tailwind.config.ts
import type { Config } from 'tailwindcss';
import path from 'path';

const uiPath = path.resolve(__dirname, './src');

export default {
  content: [
    path.join(uiPath, '/**/*.{ts,tsx,css}'),
  ],
  theme: {
    extend: {
      colors: {
        brand: '#your-color',
      },
    },
  },
  plugins: [],
} satisfies Config;

export function getMonorepoContent() {
  return [
    path.join(uiPath, '/**/*.{ts,tsx,css}'),
  ];
}

Then in each app:

// apps/astro-site/tailwind.config.ts
import { getMonorepoContent } from '@yourorg/ui/tailwind.config';

export default {
  content: [
    './src/**/*.{astro,ts,tsx}',
    ...getMonorepoContent(),
  ],
  theme: {},
  plugins: [],
};

This is the DRY approach. Single source of truth for paths.

The CSS File Gotcha: I Missed This

Here’s what burned me: I added the packages/ui path to Astro’s content, but I only included **/*.{ts,tsx}. Tailwind was scanning the TypeScript files but not the CSS files where @apply rules live.

// ❌ WRONG - Tailwind never sees @apply rules
content: ['../../packages/ui/src/**/*.{ts,tsx}'],

// ✅ RIGHT - Includes CSS files with @apply
content: [
  '../../packages/ui/src/**/*.{ts,tsx}',
  '../../packages/ui/src/**/*.css',
],

The CSS file itself doesn’t contain class selectors Tailwind can parse—it contains @apply directives. Tailwind needs to parse the CSS to extract those utilities.

Verify It Works: Check the Dependency Graph

Add this to your build script to verify Tailwind is actually scanning your shared package:

# In your Astro or React build logs, you should see:
# Scanning: /path/to/packages/ui/src/**/*.{ts,tsx,css}
# Found N utility classes

If you don’t see the UI package path in the scan output, your config is wrong.

Production Build Checklist

Before deploying:

  1. Run a production build locally (npm run build, not dev)
  2. Inspect your final CSS file for your component’s utility classes
  3. Check node_modules/.cache is cleared between builds
  4. Verify each workspace has the UI package in content (use absolute paths with path.resolve)
  5. Include both .{ts,tsx} AND .css if using @apply or CSS modules

The styles work in dev because Tailwind’s development mode never purges. Production mercilessly strips what it doesn’t find. That’s the real difference.

This pattern has held up across CitizenApp’s 9 AI features, shared across React dashboards, Astro docs sites, and Storybook. Once you wire it correctly, it’s solid—but the setup matters.

You might also like

Building something like this?

I build production-grade Python + React applications. Let's talk about your project.

Get in touch