when classes from external sources go missing from the build
Class names originating from a headless CMS field, an MDX file, or an npm component library often sit outside the reach of the Tailwind scanner. Anyone who does not understand why content scanning fails right there loses time chasing mysteriously missing CSS, even though the class is visibly sitting in the markup.
Table of Contents
- 1. Why content from external sources bypasses the scanner
- 2. Headless CMS fields: classes arriving from the API at runtime
- 3. MDX files: markdown with embedded components
- 4. Third party components from node_modules
- 5. Dynamically composed class names in the CMS context
- 6. Using the @source directive for external content
- 7. Safelist strategies for CMS driven classes
- 8. The mapping pattern: mapping CMS values to fixed classes
- 9. Solutions compared
- 10. Summary
- 11. FAQ
1. Why content from external sources bypasses the scanner
The Tailwind scanner works exclusively on files referenced by glob paths in the content configuration. This basic assumption works excellently for classic application code but reaches its limits as soon as class names originate from sources that do not exist as a file in the repository at build time. An editor filling a headless CMS field with the value bg-sky-600 creates a string that only reaches the frontend at runtime via an API response. Tailwind's content scanning never sees this string, because it simply does not exist at build time.
The same underlying problem occurs with MDX files that generate content dynamically, with npm component libraries that ship without their CSS source, and with any other scenario where class names originate outside the source files controlled by the development team. Content scanning here is not a bug but consistently follows the architectural principle that Tailwind analyzes static text at build time. Anyone who understands this boundary can counter it deliberately instead of debugging from scratch every time a style is missing.
2. Headless CMS fields: classes arriving from the API at runtime
A particularly common pitfall arises when a headless CMS like Contentful, Sanity, or Strapi allows editors to fill free text fields with Tailwind class names, for example for the background color of a hero banner. From a developer's perspective this looks flexible: the editor can adjust the look without a deploy. For content scanning, however, this is invisible, because the CMS content never lands as a source file in the repository but is only loaded via the API at runtime.
The result: the component renders class="{cmsField.bgColor}", the value from the CMS reads bg-emerald-600, but this class is completely missing from the generated stylesheet because it never appeared in any scanned file at build time. Content scanning cannot close this gap by itself because it does not make network requests and does not know any CMS API. The solution has to be on the developer side, not inside the scanner itself.
// PROBLEM: class name comes from a CMS API response at runtime,
// so it never appears as static text at build time
async function HeroBanner({ entryId }) {
const entry = await cms.getEntry(entryId);
// entry.fields.bgColorClass === "bg-emerald-600" (editor-controlled)
return <div className={`hero ${entry.fields.bgColorClass}`}>...</div>;
}
// The Oxide Engine's content scanner never sees "bg-emerald-600" because
// it only exists inside a runtime API response, not in any source file.
3. MDX files: markdown with embedded components
MDX files mix markdown prose with embedded JSX components and are used by many content platforms and documentation sites. As long as MDX files live in the repository and are covered by the content configuration, content scanning works fine, because the Oxide Engine treats MDX as plain text and searches it for class names just like any other file. The pitfall arises when MDX content is not statically in the repository but pulled in at build time from an external content repository, a git submodule, or a separate content package whose path the content configuration does not cover.
A second MDX specific problem concerns custom components that generate class names controlled by props, for example a <Callout variant="warning" /> component that internally composes different Tailwind classes depending on the variant prop. As long as this composition exists as complete strings in the component code, content scanning finds them reliably. If the class is instead dynamically concatenated from the prop value, for example ` + "`bg-${variant}-100`" + `, the same gap arises as with CMS fields.
// WRONG: dynamic concatenation breaks static scanning
function Callout({ variant }) {
return <div className={`p-4 rounded-lg bg-${variant}-100 border-${variant}-300`}>
{children}
</div>;
}
// RIGHT: fully written class strings per variant, scanner finds all of them
const VARIANT_CLASSES = {
warning: "p-4 rounded-lg bg-amber-100 border-amber-300",
danger: "p-4 rounded-lg bg-red-100 border-red-300",
info: "p-4 rounded-lg bg-sky-100 border-sky-300",
};
function Callout({ variant }) {
return <div className={VARIANT_CLASSES[variant]}>{children}</div>;
}
4. Third party components from node_modules
Another pitfall in content scanning concerns npm packages that ship Tailwind class names in their compiled code, for example a UI library whose own components were built with Tailwind. By default, the content configuration excludes node_modules for performance reasons, which means class names inside the library are not scanned, even if your own project uses the exact same Tailwind installation.
The result: the library component renders correctly structured markup with valid Tailwind classes, but because content scanning never covered the package, exactly those classes are missing from the main project's generated CSS. Some UI libraries solve this by shipping their own precompiled CSS, included independently of the main project's scan result. Other libraries explicitly recommend including their dist folder in your own content configuration so content scanning covers their classes too.
// package.json of a hypothetical UI library — two competing strategies
// Strategy A: ship precompiled CSS, no scanning needed by consumers
// import "@acme/ui-kit/dist/styles.css";
// Strategy B: ship raw source, consumer must extend their own
// content configuration to cover the library's component sources
// @source "../../node_modules/@acme/ui-kit/src/**/*.tsx";
5. Dynamically composed class names in the CMS context
A pattern that causes problems especially often in CMS driven projects is string interpolation based on editorially maintained values, for example a column count stored as a number in the CMS and then converted into a grid class. Code like ` + "`grid-cols-${columns}`" + ` looks harmless but creates the same problem for content scanning as any other dynamic concatenation: the scanner cannot know the concrete number from the CMS at build time and therefore cannot derive a complete class either.
The practical solution is to restrict editorially allowed values in the CMS itself to a limited set, for example a dropdown with 2, 3, or 4 columns, and maintain an explicit mapping table in code that holds a fully written out class for every possible value. At first glance this restriction may look like a loss of comfort for editors, but it reliably prevents content scanning from missing unpredictable values.
6. Using the @source directive for external content
Tailwind CSS v4 offers the @source directive as a targeted way to include additional paths in content scanning that automatic detection does not cover. This applies especially to cases where MDX content is pulled in from an external repository via a git submodule, or an npm library keeps its source files in an unusual path. With @source "../content-repo/**/*.mdx" a completely separate directory can be explicitly included in the scan process, regardless of whether it lives inside or outside the actual project folder.
Important here: the @source directive only extends which files get scanned, it does not solve the fundamental problem of class names that only come into existence at runtime via an API. For CMS fields with free text class names, the @source directive remains ineffective, because no file path exists in the repository containing those values. It is the right tool for externally located but statically present content, not for values generated at runtime.
/* tailwind.config.css — extend scanning to externally sourced content */
@import "tailwindcss";
@source "./src/**/*.{ts,tsx}";
/* MDX content pulled in via a git submodule outside the app's own folder */
@source "../content-repo/**/*.mdx";
/* Third-party component library's dist folder, not covered by default rules */
@source "../../node_modules/@acme/ui-kit/dist/**/*.js";
7. Safelist strategies for CMS driven classes
Where @source does not apply because class names only come into existence at runtime via an API, the safelist is the right mechanism for reliable content scanning behavior. The safelist forces the generation of specific classes regardless of the scan result by explicitly listing them in the CSS entry point. For CMS driven free text fields, it is advisable to include every theoretically possible value editors can pick in the CMS into the safelist.
It is important not to treat the safelist as a blank check: every additional safelist class lands in the final stylesheet regardless of whether it is ever used. An uncontrolled, growing safelist undermines exactly the benefit content scanning is meant to provide, namely a CSS bundle that contains exactly the classes actually in use. The safelist should therefore stay tightly coupled to the limited value list in the CMS, not serve as a blanket safeguard for arbitrary future values.
/* tailwind.config.css — safelist for CMS-driven background colors */
@import "tailwindcss";
/* Only the exact set of values editors can pick in the CMS dropdown */
@source inline("bg-sky-{100,600}");
@source inline("bg-emerald-{100,600}");
@source inline("bg-amber-{100,600}");
@source inline("bg-red-{100,600}");
8. The mapping pattern: mapping CMS values to fixed classes
The most robust solution for content scanning problems with CMS data is usually not the safelist at all, but a mapping pattern in the application code: instead of using the CMS value directly as a class name, it is treated as a semantic key that gets mapped to a fully written out Tailwind class through a table defined in code. The editor, for example, picks "accent" in the CMS instead of a concrete color class, and the code translates "accent" into bg-sky-600.
This pattern has two advantages over a pure safelist: first, content scanning finds the complete class directly in the source code of the mapping table, with no additional safelist entries at all. Second, it fully decouples the CMS data model from Tailwind's internal class names, so a later change to the color palette only requires a change in one central place in the code, instead of having to be manually maintained in every CMS entry.
9. Solutions compared
For each of the described content scanning problems, there are several conceivable approaches with different trade offs between maintainability, safety, and effort.
| Scenario | Unsafe | Recommended solution | Benefit |
|---|---|---|---|
| CMS free text field | Class straight from API | Mapping pattern in code | Class lives in source code, cannot get lost |
| MDX from external repo | Ignored, not scanned | @source pointing to external path | Full coverage without manual maintenance |
| npm UI library | node_modules ignored | Explicitly include dist folder | Library classes included in own build |
| Dynamic column count | Template literal concatenation | Fixed value list + mapping table | Limited, predictable values |
In all four scenarios, the most sustainable solution lies in the application code itself, not in the Tailwind configuration alone. The safelist remains a legitimate tool for edge cases but should never be the first choice when a mapping pattern or an extended content directive achieves the same result with less CSS overhead.
Mironsoft
Headless CMS integrations, MDX content, and Tailwind architecture
Missing CSS classes from CMS or MDX content?
We analyze why certain Tailwind classes are missing from your build, set up mapping patterns and targeted content directives, and make sure editors can manage content without letting the CSS bundle grow uncontrollably.
CMS audit
Identify editorial free text fields that contain Tailwind classes
Mapping pattern
Cleanly map CMS values to fixed, source visible class names
Content configuration
Set up @source directives for external MDX repos and npm libraries
10. Summary
Pitfalls in content scanning almost always arise where class names come from sources that are not static text in the repository at build time: headless CMS fields, externally included MDX files, npm component libraries, and dynamically composed values all share the same underlying problem. The Tailwind scanner can only find what exists as a complete string at build time, and it cannot anticipate API responses or runtime computations.
The most sustainable solution is usually a mapping pattern that maps CMS values to fixed, source visible class names. For externally located but statically present content, the @source directive helps. The safelist remains useful as a last resort for genuine free text cases but should stay tightly scoped, so content scanning continues to deliver a lean, demand driven CSS bundle.
Content Scanning Pitfalls — Key Takeaways
CMS fields
Class names from API responses are never caught by the scanner because they do not exist at build time.
Mapping pattern
Treat CMS values as semantic keys mapped to complete classes in code.
@source directive
Explicitly include external MDX repos and npm libraries in the scan process.
Safelist in moderation
Use only for genuine free text cases and keep it tightly coupled to actually possible values.