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:
- The Astro build doesn’t scan
packages/uiin its content array → Tailwind never sees the@applyclasses used in Button - The React build might scan it, but only if you explicitly added it
- In production, Tailwind purges unused classes, and your Button loses its styles in Astro only
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:
- Run a production build locally (
npm run build, not dev) - Inspect your final CSS file for your component’s utility classes
- Check
node_modules/.cacheis cleared between builds - Verify each workspace has the UI package in content (use absolute paths with
path.resolve) - Include both
.{ts,tsx}AND.cssif using@applyor 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.