Tailwind CSS v4 Monorepo
How do you distribute a design system across an Nx monorepo without styles breaking at the boundary?
Tailwind v4 replaces tailwind.config.js with CSS-first configuration. No more JavaScript presets, no createGlobPatternsForDependencies, no content arrays. Everything lives in CSS via @theme, @source, and @import. This simplifies monorepo architecture but changes every assumption from v3.
For token architecture (what the design system looks like), see Tailwind CSS v4 Theme. This page covers how that system distributes across an Nx monorepo.
Monorepo Architecture
The Design System Library
The shared preset lives entirely in CSS. No JavaScript config files.
libs/app-client/design-system/theme/src/styles/
tokens.css <- Raw CSS variables (:root, [data-theme])
tailwind-bridge.css <- @theme and @theme inline (generates utilities)
globals.css <- Base resets, scrollbar styles
animations.css <- @keyframes within @theme
tailwind-bridge.css is the v4 equivalent of tailwind.preset.js:
@theme {
/* Static tokens — don't change between themes */
--font-sans: "Inter", "Helvetica Now", sans-serif;
--font-mono: "JetBrains Mono", monospace;
--spacing: 0.25rem;
}
@theme inline {
/* Dynamic tokens — respond to theme changes */
--color-background: var(--app-bg);
--color-foreground: var(--app-fg);
--color-primary: var(--app-primary);
}
The App Entry Point
Each consuming app imports the design system and declares @source directives to scan shared libraries:
/* apps/dreamineering/drmg-marketing/src/app/global.css */
@import 'tailwindcss';
/* 1. Raw CSS variables (SSOT) */
@import '../../../../../libs/app-client/design-system/theme/src/styles/tokens.css';
/* 2. Base styles (resets, scrollbars) */
@import '../../../../../libs/app-client/design-system/theme/src/styles/globals.css';
/* 3. The @theme bridge (Tailwind preset) */
@import '../../../../../libs/app-client/design-system/theme/src/styles/tailwind-bridge.css';
/* 4. Scan shared libraries for class names */
@source "../../../../../libs/app-client/**/*.{js,ts,jsx,tsx,mdx}";
@source "../../../../../libs/app-server/ui-server-components/**/*.{js,ts,jsx,tsx,mdx}";
What Changed from v3
| v3 Pattern | v4 Replacement | Why |
|---|---|---|
tailwind.config.js | CSS @theme directive | No JS build step for config |
tailwind.preset.js | Shared CSS file with @theme | Import via @import, not require() |
content: [...] array | @source directive in CSS | Scanner config lives where styles live |
createGlobPatternsForDependencies() | Manual @source paths | Explicit is better than implicit |
@tailwind base/components/utilities | @import 'tailwindcss' | Single import replaces three directives |
darkMode: 'class' | @custom-variant dark (...) | CSS-native variant definition |
Source Scanning
The @source directive tells Tailwind's compiler where to look for class names. In a monorepo, this is the boundary between "styles that exist" and "styles that get generated."
/* Scan all client-side UI libraries */
@source "../../../../../libs/app-client/**/*.{js,ts,jsx,tsx,mdx}";
/* Scan server components that render HTML */
@source "../../../../../libs/app-server/ui-server-components/**/*.{js,ts,jsx,tsx,mdx}";
Critical rule: The path is relative to the CSS file containing the @source directive. Get this wrong and Tailwind silently generates no utilities for your shared components.
Automatic scanning: Tailwind v4 automatically scans files in the same directory tree as the CSS entry point. You only need explicit @source for files outside that tree — which is always the case for monorepo libraries.
Import Chain
The import order matters. Each layer builds on the previous:
@import 'tailwindcss' <- Tailwind's base, components, utilities
↓
@import tokens.css <- Raw CSS variables (:root, [data-theme])
↓
@import globals.css <- Element resets using those variables
↓
@import tailwind-bridge.css <- @theme maps variables to Tailwind utilities
↓
@source paths <- Tell scanner where to find class usage
If the bridge imports before the raw variables, var() references resolve to nothing. If @source paths are wrong, shared component classes get purged.
PostCSS Setup
Tailwind v4 uses @tailwindcss/postcss instead of the old tailwindcss PostCSS plugin:
// postcss.config.js (in the app root)
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
},
};
For Vite-based apps, use the Vite plugin instead:
// vite.config.ts
import tailwindcss from '@tailwindcss/vite';
export default {
plugins: [tailwindcss()],
};
Class Name Patterns
Tailwind v4's scanner is static — it cannot detect dynamically constructed class names.
// BAD — scanner cannot detect these
className={`text-${color}-500`}
className={`bg-${variant}`}
// GOOD — full class names, scanner finds them
className={cn(
variant === 'primary' && 'bg-primary text-primary-foreground',
variant === 'destructive' && 'bg-destructive text-destructive-foreground',
)}
Use cn() (from clsx + tailwind-merge) and cva() (class-variance-authority) for variant mapping. Both produce full class name strings the scanner can find.
Debugging Checklist
When styles fail to apply in a consuming app, work through this sequence:
| # | Check | How |
|---|---|---|
| 1 | Source paths | Verify @source relative path from the CSS file's location. Print pwd from the CSS file's directory and trace |
| 2 | Import order | Confirm @import 'tailwindcss' comes first, then tokens, then bridge |
| 3 | PostCSS config | Confirm @tailwindcss/postcss is in the app's postcss.config.js |
| 4 | Variable resolution | DevTools Computed tab: check --app-* variables resolve on :root |
| 5 | Token mapping | DevTools: check bg-primary resolves to a real color, not transparent |
| 6 | Cache | Run npx nx reset to clear Nx and Tailwind daemon caches |
| 7 | Dynamic classes | Grep for template literal class names — replace with cn() / cva() |
| 8 | Browser support | Safari 16.4+, Chrome 111+, Firefox 128+ required for v4 features |
The most common failure: @source path is wrong, so Tailwind's scanner never sees shared component classes. Styles exist in the design system but never get generated in the app's CSS output.
Rendering Verification
"Present in the DOM" is not "visible to a human." After any token or theme change, run the 5-check verification:
- Tokens resolve (no
transparenton visible elements) - Text visible (color differs from background)
- Images render (width and height > 0)
- No overflow (zero horizontal scrollbar)
- Fonts loaded (computed font matches intended)
See Design Verification lesson for the full audit script.
UI Libraries
| Designed | Headless |
|---|---|
| Catalyst UI | Headless UI |
| shadcn/ui | Radix UI |
| Daisy UI |
Context
- Tailwind CSS v4 Theme — Token architecture: @theme layers, OKLCH, semantic naming, multi-theme pattern
- Rendering Verification — Verify tokens actually resolve
- UI Design Benchmarks — Pass/fail thresholds for visual quality
- Engineering Quality Benchmarks — Module budgets and build performance
- Engineering Anti-Patterns — Barrel blowouts that affect CSS scanning
- Nx Monorepo — Build system and boundary enforcement
- Component Driven Development — Design system methodology
- Product Design Metrics — Track whether design quality is improving
Resources
- Tailwind CSS v4 Docs
- Tailwind Labs
- Tailwind Plus — Templates and Catalyst UI Kit
- Play Tailwind
- Cheatsheet
- Dev Tools for Tailwind