Tailwind CSS Content Configuration: Pitfalls with Globs and Monorepos
AI generated
</>
tw
Tailwind CSS · Build Tooling · Monorepo · Configuration
Setting Up Tailwind CSS Content Configuration Correctly
Globs, monorepos, and dynamic class names without nasty surprises

Content configuration decides which files Tailwind CSS scans for used utility classes, and therefore directly controls bundle size, build time, and whether styles even reach production. Knowing the pitfalls with globs, monorepo symlinks, and dynamic class names saves hours of debugging missing utility classes on a live system.

18 min read content array · @source · monorepo · pnpm workspaces Tailwind v3 & v4

1. Why content configuration decides everything

The content configuration is the list of file paths in which Tailwind CSS searches for used utility classes. Anything not found as a string in these files does not end up in the final CSS bundle, no matter how valid the class would theoretically be. If the content configuration is scoped too narrowly, styles disappear in production. If it is scoped too broadly, build times balloon and random strings from log files or vendor folders risk being falsely recognized as class names.

In practice, the content configuration is exactly where many teams first stumble over Tailwind CSS, usually only after deployment. A component looks correct locally because the dev server watches every file anyway, but the production build is suddenly missing border colors or spacing. The reason is almost always an incomplete or badly structured content configuration, not a bug in the compiler itself. This article works through the most common pitfalls systematically, from glob patterns to monorepos to the automatic detection in Tailwind CSS v4.

2. Explicit content array in v3 versus auto detection in v4

In Tailwind CSS v3, the content configuration is an explicit array in tailwind.config.js that lists every path group to scan as a glob pattern. If a directory is missing from that array, Tailwind never sees the classes used inside it, no matter how correct the code otherwise is. This is the most common single mistake in new projects: a folder for shared components gets created, but nobody adds it to the content array, because the configuration was only written once at project start and then forgotten.

Tailwind CSS v4 inverts this model: the content configuration is derived automatically from the project directory by default, starting from the file that contains @import "tailwindcss". The scanner walks recursively through all subdirectories, but respects .gitignore rules and automatically excludes known binary formats as well as node_modules. This significantly reduces the classic "forgot the directory" failure mode, but it also changes the mental model: instead of maintaining an allowlist, in v4 you tend to maintain a short list of exceptions.


// tailwind.config.js — Tailwind CSS v3 explicit content configuration
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    './src/**/*.{html,js,jsx,ts,tsx,vue}',
    './app/design/frontend/**/*.phtml', // Hyva templates
    // Common mistake: shared component folder outside src/ forgotten here
    './packages/ui-components/**/*.{jsx,tsx}',
  ],
  theme: {
    extend: {},
  },
  plugins: [],
};

The switch to auto detection in v4 does not automatically solve every problem in the content configuration. Symlinks, unusual build output directories, and files outside the project root still are not captured automatically. It therefore remains important to understand the basic principles of the content configuration, even though v4 simplifies a lot. Anyone migrating from v3 to v4 should test the auto detection before fully removing the old content array.

3. Monorepo pitfalls: symlinks and package boundaries

In monorepos using pnpm, Yarn, or Turborepo workspaces, one of the biggest traps in the content configuration lies in how package managers link dependencies. An internal UI package is often wired in via a symlink inside node_modules, so other packages can import it like a normal dependency. If node_modules is blanket ignored, as most default configurations do, the content of the internal package is never scanned either, even though the actual source code lives inside the project and is under active development.

The second typical content configuration trap in a monorepo concerns package boundaries: every frontend package often brings its own Tailwind configuration, but shared base components live in a separate design system package. If that design system package is not explicitly listed in the consuming package's content configuration, utility classes used inside it never show up in the final build. The symptom is deceptive because it looks correct in the design system package's own Storybook, but breaks in the consuming app.


// tailwind.config.js — monorepo content configuration (consuming app)
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    './src/**/*.{ts,tsx}',
    // Explicitly resolve the real package path, not the symlinked
    // node_modules entry, so pnpm workspace packages are actually scanned.
    '../../packages/design-system/src/**/*.{ts,tsx}',
    '../../packages/ui-components/src/**/*.{ts,tsx}',
  ],
};

A pragmatic fix for the content configuration in monorepos is to always reference the real filesystem path of the source package instead of the symlinked node_modules entry. This sidesteps the problem that many build tools do not resolve symlinks during glob matching. In Turborepo setups with a shared remote cache, it is also worth declaring the content configuration as part of the inputs field in turbo.json, so a changed path in one package correctly invalidates the cache for dependent packages.

4. Dynamic class names and string concatenation

Tailwind CSS scans source files as plain text, not as executed code. That means: the content configuration can only find complete class names that appear as a contiguous string in the source code. A construct like `text-${color}-500` will never be recognized, because the compiler has no idea at build time which value color takes at runtime. This is independent of the content configuration itself, but a fundamental property of the static scanning approach Tailwind CSS uses for performance reasons.

The correct way to reconcile dynamic behavior with a static content configuration is a complete mapping table in which every possible combination appears as a full string. That table is then evaluated at runtime via object access instead of string concatenation. The scanner sees every variant as a complete string in the source code this way, regardless of which branch actually executes at runtime.


// WRONG: dynamic concatenation — Tailwind's static scanner cannot see this
function Badge({ color }) {
  return <span className={`bg-${color}-100 text-${color}-700`}>Status</span>;
}

// RIGHT: full class strings in a lookup map — scanner finds every literal
const BADGE_STYLES = {
  green: 'bg-green-100 text-green-700',
  red: 'bg-red-100 text-red-700',
  amber: 'bg-amber-100 text-amber-700',
};

function Badge({ color }) {
  return <span className={BADGE_STYLES[color]}>Status</span>;
}

Safelist entries in the content configuration are an escape hatch for cases where a full mapping table is not practical, for instance when class names come entirely from the backend. They should remain the exception, though, because every safelist entry potentially pulls unused CSS rules into the bundle and dilutes the very strength of the content configuration, namely precise tree shaking. A well maintained mapping table is almost always the more maintainable and leaner solution.

5. The @source directive for explicit paths in v4

Even though Tailwind CSS v4 automates most of the content configuration, there are situations where the heuristic falls short: directories outside the project root, generated files in unusual build output folders, or paths explicitly excluded by .gitignore that are nevertheless relevant. For exactly these cases there is the @source directive right in the CSS, which adds extra paths to the content configuration without creating a separate JavaScript configuration file.

The @source directive is declarative and sits right next to the @import "tailwindcss" statement, which makes the content configuration considerably more visible to new team members than a deeply nested array in a separate file. Multiple @source lines can be combined, each with its own glob pattern. There is additionally @source not, to explicitly exclude certain subpaths from automatic detection, for instance generated test fixtures that happen to contain valid looking utility names.


/* app.css — Tailwind CSS v4 with explicit @source additions */
@import "tailwindcss";

/* Include a package outside the automatically detected project root */
@source "../../packages/legacy-widgets/src";

/* Include generated files from an unusual build output directory */
@source "./storybook-static/iframe-sources";

/* Exclude fixtures that contain false-positive utility-like strings */
@source not "./tests/fixtures/**/*.json";

When migrating from v3 to v4, it is worth doing a comparison: every entry in the old content array should either be covered by auto detection or carried over as an explicit @source line. Skipping this comparison risks exactly the kind of content configuration gap that was already fixed once before the migration and silently reappears afterward.

6. Ignore patterns and excluding node_modules correctly

The flip side of a too narrow content configuration is a too broad one: if the entire project root, including node_modules, .git, and build output folders, gets scanned by accident, the build slows down considerably because Tailwind searches through tens of thousands of irrelevant files. The default ignore list covers the usual suspects, but project specific output folders like dist, .next, var/view_preprocessed in Magento setups, or coverage reports from test runs often need to be manually added to the content configuration as exclusions.

In v3 exclusion happens through deliberately leaving something out of the content array, since only explicitly listed paths get scanned at all. In v4 with auto detection, the reverse approach is needed: a .gitignore entry or an @source not line prevents a path from being picked up into the automatic content configuration. It matters that build output folders remain consistently excluded in both models, because compiled CSS and JavaScript themselves can contain Tailwind-like class names and lead to duplicate but inconsistent matches.


# .gitignore entries that also shape Tailwind v4's automatic content detection
node_modules/
dist/
.next/
coverage/
var/view_preprocessed/
storybook-static/

7. Performance impact of overly broad globs

Every additional file in the content configuration costs scan time, even though modern implementations like the Rust based scanner in v4 work extremely fast. In small projects, the difference between a precise and an overly broad content configuration is barely measurable. In large monorepos with tens of thousands of files, though, the overhead adds up noticeably, especially in watch mode, where every file change can trigger another comparison against the content configuration.

A common performance mistake is an overly generic glob pattern like ./**/* without an extension filter, which accidentally includes binary files, images, and lock files. These files contain no class names but still cost read time. The recommendation is to always specify explicit file extensions in the content configuration and name directories as granularly as possible, instead of relying on a single catch-all wildcard.

8. Debugging: which files are actually scanned

When a utility class is missing from the production build, the first diagnostic question is always: is the file containing the class even covered by the content configuration? The Tailwind CLI offers a debug mode for exactly this, logging every scanned file. Checking that log directly against the expected path is much faster than gradually ruling out CSS specificity, cache issues, or build order as the cause.

A second diagnostic step is manually searching for the missing class as plain text in the generated CSS output. If it does not show up there at all, the problem almost always lies in the content configuration, whether from missing paths, wrong file extensions, or one of the monorepo symlink traps. If the class does show up in the CSS but not in the rendered HTML, the cause is elsewhere, for example a wrong order of CSS cascade layers.


# Tailwind CLI debug flag — lists every file that matches
# the current content configuration during a single build
npx tailwindcss -i ./src/app.css -o ./dist/app.css --content-glob-debug

# Quick sanity check: does the generated CSS contain the expected class at all?
grep -c "bg-emerald-600" ./dist/app.css

9. Content configuration compared directly

The table below summarizes the most common mistakes in the content configuration and contrasts them with the recommended solution. Choosing the right approach directly affects whether styles reach production completely and how long a build takes in large projects.

Situation Error-prone Recommended content configuration Benefit
Monorepo package Referencing the node_modules symlink State the real source path in content/@source Utility classes in the package are reliably found
Dynamic class `text-${color}-500` Full strings in a mapping table Static scanner recognizes every variant
v4 special path Blindly trusting auto detection @source for paths outside the root No silent gaps for unusual folders
Build output Scanning dist/ and coverage/ too Exclude explicitly via .gitignore / @source not Shorter build time, no duplicate matches
Debugging a missing class Suspecting CSS specificity first Check --content-glob-debug Root cause in seconds instead of hours

Anyone who runs through this table as a checklist before every larger refactoring drastically reduces the number of production incidents traceable to an incomplete content configuration. Especially in growing monorepos, a recurring review meeting where the content configuration of every package is checked against the actual folder structure pays off.

Mironsoft

Tailwind CSS architecture, monorepo setups, and Hyvä frontend development

Missing styles after every deployment?

We audit your Tailwind CSS content configuration, find monorepo pitfalls and dynamic class names that trick the scanner, and build you a configuration that stays stable even as your package structure grows.

Configuration audit

Systematically check content paths, symlinks, and ignore rules

Monorepo setup

Set up content configuration for pnpm and Turborepo workspaces

v4 migration

Migrate the content array to @source and auto detection

10. Summary

The content configuration of Tailwind CSS is not a one time setting, but a living part of the project architecture that must grow along with every new directory, every monorepo package, and every migration. In v3 that means a well maintained, explicit content array. In v4 that means trusting auto detection, complemented by targeted @source lines for anything outside the automatically covered area.

The most common sources of error remain the same across both versions: symlinks in monorepos, dynamically composed class names, and globs that are either too broad or too narrow. Anyone who regularly validates the content configuration against the actual project structure and uses the Tailwind CLI's debug mode as the first diagnostic step when styles go missing avoids the vast majority of production incidents around vanished utility classes.

Tailwind CSS Content Configuration — The Essentials at a Glance

v3 vs. v4

Explicit content array in v3, automatic detection with @source additions in v4. Compare both models against each other during migration.

Monorepo symlinks

Always reference the real source path, never the symlinked node_modules entry of a workspace package.

Dynamic classes

Complete class names in a mapping table instead of string concatenation at runtime.

Debugging

--content-glob-debug shows every scanned file and saves hours when styles are missing in production.

11. FAQ: Tailwind CSS Content Configuration

1What does content configuration do exactly?
It defines which files are scanned for utility classes. Only found, complete class names end up in the bundle.
2Is v4 auto detection always enough?
For standard projects, yes. Monorepo symlinks or paths outside the root need additional @source lines.
3Why aren't dynamic class names recognized?
The scanner reads source files as text without executing code. A string composed at runtime never exists as a complete string in the source.
4How do I solve dynamic class names cleanly?
With a complete mapping table and object access instead of string concatenation at runtime.
5Why are classes missing from a monorepo package?
Usually a node_modules symlink that gets blanket excluded. Add the real source path of the package explicitly.
6Does a too broad configuration slow the build?
Yes. Excluded folders like dist, node_modules, and coverage should consistently stay out of the content configuration.
7How do I find out if a file is scanned?
The Tailwind CLI debug mode logs every scanned file. Check it directly against the expected path.
8Use safelist instead of a mapping table?
Only as an exception. Safelist pulls potentially unused rules into the bundle, a well maintained mapping table is usually leaner.
9Keep the content array during migration?
Check every entry first: either auto detection already covers it or carry it over as an @source line.
10Most common monorepo mistake?
A shared design system package is missing from the content array of the consuming app, even though its classes are used there.