Content Scanning Pitfalls: Headless CMS, MDX & More
AI generated
</>
tw
Tailwind CSS · Content Scanning · Headless CMS · MDX
Content Scanning Pitfalls in Headless CMS and MDX
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.

17 min read Headless CMS · MDX · npm Packages · Safelist Tailwind CSS v4

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.

11. FAQ: Content Scanning with Headless CMS and MDX

1Why is a CMS class missing?
CMS values arrive at runtime via an API and do not exist in source code at build time.
2How do I fix CMS class problems?
A mapping pattern: treat the CMS value as a semantic key mapped to fixed classes in code.
3Does scanning work normally in MDX?
Yes, as long as files live in the repository and are covered by the content configuration.
4Why are npm library classes missing?
node_modules is excluded by default. The dist folder must be explicitly included.
5What exactly does @source do?
Extends covered files and directories, but does not solve classes created only at runtime.
6Is safelist the right solution?
For limited value lists yes, for free text a mapping pattern with limited CMS choice is usually better.
7What about a dynamic column count?
Use a mapping table with fully written out classes per allowed value.
8Can an oversized safelist hurt?
Yes, every safelist class lands in CSS regardless of usage and inflates the bundle.
9How do I spot external causes?
Check whether the class fully exists in a covered file. If missing there, it is usually CMS, MDX, or node_modules.
10Is precompiled CSS worth it?
Yes, if a library ships its own CSS, it need not be included in your own content scanning.