Tailwind CSS v4: setup patterns for content-managed front ends

Last updated: September 23, 2026

Tailwind v4 changed enough that habits carried over from v3 will mislead you: configuration moves into CSS, prefixes use a different syntax, and generated CSS lands in native cascade layers. This article covers the decisions worth making on day one of a new build, based on running v4 in production on content-managed sites.

Summary

  • Configure in CSS, and tell Tailwind exactly which files to scan.

  • Define tokens once as CSS variables; alias them into @theme rather than restating values.

  • Arbitrary values are one-offs — anything recurring belongs in the theme.

  • Use one cn() helper for class merging, configured with your custom scales.

  • Don't build class names by concatenation; map inputs to complete class strings.

  • Mobile-first for pages, container queries for components; one helper for per-breakpoint values.

  • Give CMS rich text prose, not hand-restored element defaults.

  • Decide up front how Tailwind coexists with CSS you don't own.

1. Configure in CSS, and scan deliberately

v4 moves the primary configuration model into CSS: @theme, @plugin, @custom-variant, and @source. A legacy JavaScript config still works via @config, but it no longer carries corePlugins, safelist, or separator. For a new build, keep configuration in CSS unless you have a specific compatibility reason not to.

@import "tailwindcss" source(none);
@source "../components/**/*.{ts,tsx}";
@source not "../components/legacy";
@plugin "@tailwindcss/typography";

source(none) is the part worth deliberating over. Tailwind scans source files as plain text — it doesn't parse code — so broad automatic detection manufactures utilities out of any string that looks like a class name, including class names in markup that is actually styled by a stylesheet you don't own. This can silently break working components. Explicit @source makes the output deterministic.

Caveat: @source not excludes a path, not a name. Because matching is textual, a utility is still generated if the same name appears in any other scanned file.

2. Alias tokens into @theme rather than restating them

Define tokens once as CSS custom properties and reference them from the theme:

@theme inline {
  --color-brand-primary: var(--brand-primary);
}

Utilities backed by those values resolve through var(), so changing a theme is a variable override rather than a CSS rebuild — which is what makes live preview and per-environment theming practical.

Use inline whenever a theme variable references another CSS variable. The utility then points at your variable directly, so an override works wherever it lands, including on a subtree such as a branded section. Tailwind's documentation recommends inline for exactly this case.

Caveat: without inline, Tailwind emits an intermediate --color-brand-primary on :root and the alias resolves there, so overrides must also land on :root. That is workable, but choose it deliberately rather than discovering it. Keep the non-inline form if anything outside Tailwind reads --color-* directly, because inline means that variable is never emitted.

Dark mode: dark: follows prefers-color-scheme by default. Redefine it with @custom-variant dark if your design system switches themes via a class or data attribute.

Generated themes: If part of your theme is generated from an external source of truth, treat those files as build artifacts: regenerate them in dev and build, and keep hand-written additions in your own entry stylesheet. Anything edited inside a generated file is lost on the next regeneration.

One copy only: Don't keep a second copy of the token set for another consumer — the copies diverge quietly.

3. Arbitrary values are for genuinely one-off cases

If the same value starts recurring, promote it into the theme. Otherwise the token and the hardcoded copy drift independently, and nothing in the build tells you.

  • Custom utilities: use @utility, which registers them so they work with variants like hover: and lg:.

  • @apply: keep it for custom CSS that needs to reuse existing utilities — overriding a third-party component, for instance — rather than as a reusable-utility abstraction.

4. Merge classes through one helper, configured with your scales

extendTailwindMerge returns a new merging function rather than mutating the default instance. Calling it without consuming the return value changes nothing.

import { extendTailwindMerge } from "tailwind-merge";
import clsx from "clsx";

const twMerge = extendTailwindMerge({
  extend: { theme: { spacing: ["container-md", "spacer-lg"] } },
});

export const cn = (...inputs) => twMerge(clsx(inputs));

tailwind-merge understands Tailwind's built-in scales, but custom theme values may need to be added to its configuration. In one verified case, color utilities merged correctly while custom spacing keys weren't recognised and both classes survived — a className override quietly failed to win, which is easily misdiagnosed as a specificity problem.

5. Don't build class names by concatenation

bg-${color}-500 can't be detected by Tailwind's static source scanner, so the expected utility may never be generated. Map dynamic inputs to complete class strings instead:

const tone = {
  danger: "bg-red-600 text-white",
  muted: "bg-gray-100 text-gray-700",
};

Where values genuinely come from a CMS and a static map isn't possible, generate the @source inline(...) safe list from the same token definition that builds the theme, so the two can't drift. Keep it narrow — every safe listed combination contributes CSS even if it never appears statically.

This is also an authoring question. Where you design the components yourself, design choices can reach editors as constrained token pickers and component variants rather than a free-text CSS class field, which keeps the generated set finite and the content refactorable.

6. Variants: mobile-first, with an agreed order

Unprefixed is the mobile default; add md: / lg: upward. Where a component receives per-breakpoint values as input, resolve them through one shared helper so breakpoint policy lives in one place:

resolveViewPort({ mobile: "sm", tablet: "base", desktop: "lg" }, "text-{value}");
// → "text-sm md:text-base lg:text-lg"

Container queries: Viewport breakpoints answer "how wide is the page", which is the wrong question for a component an editor can drop into any slot. v4 has container queries built in — no plugin required — so a component can size itself against its own container:

<div class="@container">
  <article class="@md:flex @lg:gap-8"> … </article>
</div>

Note that the container scale is its own: @md is 28rem, not the 48rem of the viewport md:.

Ordering: Stacked variants read outermost-first, so dark:md:hover:underline compiles to:

@media (prefers-color-scheme: dark) {
  @media (width >= 48rem) {
    &:hover { … }
  }
}

Agree on a canonical order early, or the same intent gets written three different ways.

Hover on touch: v4 wraps hover: in @media (hover: hover), so hover styles no longer apply on touch devices. If you used hover as a tap affordance in v3, that behaviour is gone.

Focus: Use focus-visible: for visible keyboard-focus treatments and focus: where the styling genuinely needs to respond to focus itself.

Transitions: Where browser support allows, starting: covers entry transitions without JavaScript.

7. Give CMS rich text a typographic scope

Preflight resets list markers, heading sizes, and default margins. That is correct for a document styled entirely in Tailwind, and wrong for HTML authored in a CMS that assumed browser defaults.

@tailwindcss/typography gives those bodies a scale you control via prose, and not-prose opts a subtree back out — useful where a CMS body embeds a component that shouldn't inherit the typographic scale. Restoring element defaults by hand per component becomes unmaintainable quickly.

8. Decide how Tailwind coexists with CSS you don't own

These are two separate problems with two separate answers.

Name collisions → use a prefix. v4 puts the prefix first rather than as a hyphenated stem:

@import "tailwindcss" prefix(tw);   /* tw:flex, tw:hover:bg-red-500 */

If your project also uses tailwind-merge, keep its prefix configuration aligned with Tailwind's.

Priority → own the layer. In the normal author cascade, unlayered author styles take precedence over styles inside author cascade layers, regardless of specificity within those layers. Since v4 emits into @layer theme, base, components, utilities, a vendor stylesheet loaded unlayered outranks every Tailwind utility, and raising specificity doesn't fix the ordering. Where you control the import, wrap external CSS in a layer you own — cheap at setup, much harder to retrofit once conflicts are spread across components.

CSS Modules stay a narrow escape hatch for what utilities express poorly, such as vendor pseudo-elements. Bridging back via currentColor lets those styles keep inheriting from surrounding utility classes.

Related

For a worked example of most of this in one place, Uniform's Component Starter Kit is built on Tailwind with the Design Extensions integration, which is where the constrained-authoring approach in section 5 comes from.