Build-Pipeline Layer Automation

A layer stack is only a contract if every byte of shipped CSS honours it, and in a bundled application that guarantee cannot survive on discipline alone — this guide, part of Architecture Patterns & Design System Scaling, shows how to move the @layer order out of a hand-edited comment and into your build pipeline, where PostCSS or Vite injects it first and Stylelint validates it in CI. The failure mode it prevents is subtle: the cascade looks correct in dev, then a code-split chunk loads in a different order in production and a utilities rule silently stops winning. That class of bug never shows up in a unit test, so the pipeline has to be the thing that catches it.


Build-pipeline layer automation flow A horizontal flow: source CSS partials enter a PostCSS or Vite build pipeline, which prepends the single canonical layer manifest to every emitted chunk, producing a CI-validated bundle whose first at-layer statement is asserted against the canonical order. Layer order becomes a build artifact, not a convention Source CSS @layer partials Build pipeline PostCSS / Vite Layer manifest prepended first Validated bundle CI-checked order the same manifest string feeds injection and the Stylelint allowlist

Concept: why layer order must be enforced at build time

The cascade layer specification registers a layer name the first time the browser encounters it, and that registration is permanent for the document. In a single hand-authored stylesheet this is trivial to control: you write @layer reset, base, themes, components, utilities, overrides; on line one and every later block slots into the right named tier. The rule that governs this — that the understanding layer declaration order is fixed at first sight — is exactly what makes a bundler dangerous.

Modern build tools do not ship one stylesheet. Vite, webpack, Rollup, and Parcel split CSS per entry point and per dynamically imported chunk, then let the browser load those chunks in whatever order the module graph and the network resolve them. If your @layer declaration lives inside a component partial rather than in a globally-first position, then whichever chunk happens to parse first is the one that registers the layer order. Change an import, add a route, or let a vendor chunk win the race, and the registered order flips. Nothing errors. The build succeeds, the types check, the tests pass — and a rule you carefully placed in utilities no longer beats components because the browser now thinks components was declared last.

The canonical stack this site uses everywhere is:

/* The single source of truth for precedence. Every emitted CSS artifact
   must register this exact order before it registers any layered rule,
   or the cascade contract is only true by luck of chunk-load timing. */
@layer reset, base, themes, components, utilities, overrides;

Enforcing this at build time means two distinct guarantees. First, injection: the manifest statement is physically prepended to every emitted CSS file, so no chunk can register a different order first. Second, validation: a CI check reads the shipped output and fails the build if any artifact’s first @layer statement deviates from the canonical string, or if any selector was authored outside a named layer. Injection makes the correct thing automatic; validation makes the incorrect thing impossible to merge. You need both, because injection can be bypassed by an unlayered @import and validation without injection just turns every routine build red.

How tools inject and validate the manifest

The mechanics split cleanly along the injection/validation line, and it helps to see what each tool actually manipulates.

Injection is a text-prepend operation. A PostCSS plugin walks the AST of each processed file and, if the first node is not already the canonical @layer statement, inserts it. Vite performs the same prepend but does it per emitted chunk, because Vite’s concern is the split output, not the source partial. The injected node carries no rules — it is a bare name declaration, which is idempotent: re-declaring @layer reset, base, ...; in a second chunk does nothing because the browser already registered those names from the first chunk it parsed. This idempotency is what makes per-chunk injection safe rather than wasteful.

Validation is an assertion over emitted bytes. Stylelint inspects source files for two violations — selectors written outside any @layer block, and layer names that are not on your approved list. A post-build script closes the remaining gap by parsing the output (the thing the browser actually receives) and asserting its leading @layer statement equals the canonical order. Source-level linting cannot catch a bundler that reorders chunks, and output-level assertion cannot tell you which source file introduced an unlayered rule, so the two checks are complementary rather than redundant.

The subtle part is keeping injection and validation in agreement. If the PostCSS step prepends one order and the Stylelint allowlist encodes another, you get builds that pass linting but ship a broken cascade, or vice versa. The fix is a single source of truth — one layers.css file or one exported constant — that both the injector and the linter read from. That is the theme of every pattern below.

Named patterns

Pattern 1 — PostCSS manifest injection

The most portable approach, because PostCSS runs under nearly every bundler. A tiny plugin prepends the canonical declaration to any stylesheet whose first meaningful node is not already that declaration. Because the operation is idempotent, it is safe to run on partials, on the entry file, and on concatenated output alike.

// postcss-layer-manifest.js
// WHY: guarantees the canonical @layer order is the first statement of every
// file PostCSS touches, so no partial can accidentally register a rival order.
const CANONICAL = "reset, base, themes, components, utilities, overrides";

module.exports = () => ({
  postcssPlugin: "layer-manifest",
  Once(root, { AtRule }) {
    const first = root.first;
    const alreadyDeclared =
      first && first.type === "atrule" && first.name === "layer" &&
      first.params.replace(/\s+/g, " ").trim() === CANONICAL;
    // WHY: skip if the manifest is already first — re-inserting would be
    // harmless (idempotent) but keeps the output diff clean.
    if (!alreadyDeclared) {
      root.prepend(new AtRule({ name: "layer", params: CANONICAL }));
    }
  },
});
module.exports.postcss = true;
// postcss.config.js
module.exports = {
  plugins: [
    require("postcss-import"),          // inline @imports FIRST so ordering is real
    require("./postcss-layer-manifest"), // then guarantee the manifest leads
  ],
};

The plugin order matters: postcss-import must run before the manifest injector so that imported partials are already inlined when the manifest is prepended — otherwise an @import without a layer() annotation could still smuggle unlayered rules ahead of your declaration.

Pattern 2 — Vite single-entry layer order

Vite’s dev server and its production build treat CSS differently, and the trap is that dev serves one merged stylesheet while production emits split chunks. The robust setup does two things: import a dedicated manifest file first in the JS entry, and register the PostCSS injector so every production chunk also leads with the declaration.

// vite.config.js
import { defineConfig } from "vite";
import layerManifest from "./postcss-layer-manifest.js";

export default defineConfig({
  css: {
    // WHY: the same injector from Pattern 1 runs per emitted chunk, so a
    // code-split route's stylesheet still registers the canonical order first.
    postcss: { plugins: [layerManifest()] },
  },
});
// main.js — the manifest import must be the FIRST CSS side-effect import.
// WHY: in dev, Vite serves CSS in import order; putting layers.css first
// registers the order before any component stylesheet is injected.
import "./styles/layers.css"; // contains only: @layer reset, base, themes, components, utilities, overrides;
import "./styles/reset.css";
import "./styles/base.css";
import "./styles/components.css";

This pattern is developed step by step, with the CI validation script and the full working config, in the child guide on automating layer order with PostCSS and Vite.

Pattern 3 — Stylelint layer-name allowlist

Injection guarantees the order is present; the allowlist guarantees nobody invents a rogue layer or leaves a rule unlayered. A cascade-layers plugin, fed the same canonical array, flags any selector outside a named layer and any @layer name not on the list.

// stylelint.config.js
import { LAYERS } from "./layers.config.js"; // ["reset","base","themes","components","utilities","overrides"]

export default {
  plugins: ["@csstools/stylelint-plugin-cascade-layers"],
  rules: {
    // WHY: forces every selector into a declared layer — an unlayered rule
    // would sit above all named layers and silently beat the whole stack.
    "@csstools/cascade-layers/require-defined-layers": [true, { layerOrder: LAYERS }],
    // WHY: no author !important; layer order should decide every conflict.
    "declaration-no-important": true,
  },
};

Because layers.config.js is the single exported source of truth, the same array drives the PostCSS injector, the Stylelint allowlist, and the post-build assertion — they cannot drift apart.

Interaction with adjacent features

Build automation does not stand alone; two adjacent concerns change what your pipeline must emit.

Framework integration and @layer wrapping determines what enters your components and vendor tiers. When a framework injects styles at runtime (styled-components, Emotion, Vue SFC <style> blocks), those rules can arrive after your manifest chunk and, if they are unlayered, sit above every named layer. Your build step should wrap framework output in a layer — via the framework’s own layer option or a PostCSS pass — so the manifest you injected still governs it. The pipeline and the framework wrapper are two halves of the same guarantee: one fixes the order, the other makes sure runtime-injected CSS respects it.

Layer browser support and polyfills changes what your build emits for older engines. If your support matrix still includes pre-2022 Safari or Chrome, the @csstools/postcss-cascade-layers polyfill rewrites your layers into specificity chains at build time. That polyfill must run after the manifest is injected and after imports are inlined, because it needs to see the full, correctly-ordered layer set to compute the equivalent specificity. Sequencing the manifest injector, the import inliner, and the polyfill correctly in one PostCSS chain is itself part of build-pipeline automation.

DevTools and Stylelint diagnostic workflow

When a layered build misbehaves, work from the shipped artifact backwards to the source.

  1. Confirm the emitted order. Open the production CSS file (or the network response in the browser’s Network tab) and check that its first non-comment statement is @layer reset, base, themes, components, utilities, overrides;. If it is missing or different, the injector did not run on that chunk.
  2. Inspect the live layer tree. In Chrome DevTools, open Elements → Styles and use the Cascade Layers grouping (Chrome 99+). Every matched rule shows its layer; a rule under (unlayered) is the smoking gun — some CSS entered without passing your injector.
  3. Trace the winning declaration. In Elements → Computed, expand a property to see the winning rule’s layer name in brackets. If a [components] rule wins where you expected [utilities], the registered order is inverted — almost always a chunk-load ordering problem, not a specificity one.
  4. Reproduce the failure in the linter. Run Stylelint locally against source so it names the file and line of any unlayered selector:
# WHY: --max-warnings 0 turns any unlayered selector or unknown layer name
# into a non-zero exit code, matching how CI will treat it.
npx stylelint "src/**/*.css" --max-warnings 0
  1. Assert the output in CI. The source linter cannot see chunk ordering, so a post-build script parses each emitted bundle’s first @layer statement and fails if it is not the canonical string. That script is the definitive gate.

Migration checklist

Follow these steps to move an existing layered codebase from a hand-edited declaration to an automated, CI-enforced one:

  1. Establish one source of truth. Create a layers.config.js exporting the ordered array and a matching layers.css containing only the @layer reset, base, themes, components, utilities, overrides; statement. Delete every other hand-written copy of the declaration.
  2. Add the PostCSS injector. Drop in the manifest plugin from Pattern 1 and place it after postcss-import so imports are inlined before the manifest is prepended.
  3. Wire the manifest into Vite. Import layers.css as the first CSS side-effect in your JS entry, and register the injector under css.postcss so split chunks also lead with the declaration.
  4. Install and configure Stylelint. Add the cascade-layers plugin with require-defined-layers fed by layers.config.js, plus declaration-no-important.
  5. Add the post-build assertion. Write a script that reads every file in your CSS output directory and asserts its first @layer statement equals the canonical order; exit non-zero on any mismatch.
  6. Gate both checks in CI. Add a pull_request job that runs npx stylelint "**/*.css" --max-warnings 0 and the assertion script, so a regression in either injection or authoring fails before merge.

Edge cases and gotchas

The manifest chunk gets tree-shaken or deduped away

If layers.css contains only a name declaration and no rules, an aggressive bundler may consider it “empty” and drop it, or hoist it below a component chunk during deduplication. Guard against this by importing it as an explicit side-effect (import "./styles/layers.css") and, in package.json, marking it in sideEffects so it is never eliminated. The per-chunk PostCSS injector is the belt-and-braces backup for when the JS-side import is not enough.

@import without layer() reintroduces unlayered rules

Injecting the manifest fixes the order but not membership. A plain @import url("widget.css"); inside a partial pulls those rules in as unlayered author styles that sit above your entire stack. Run postcss-import before the injector so imports are inlined and visible to Stylelint, and lint for bare @import in source.

Runtime-injected styles bypass the build entirely

CSS-in-JS libraries and framework <style> blocks insert rules into the document at runtime, long after your build-time injector ran. Those rules never pass through PostCSS, so they arrive unlayered unless the framework is configured to wrap them. This is where build automation hands off to framework integration and @layer wrapping; the pipeline cannot police what it never compiles.

Dev and production disagree because only one path injects

A common trap: the JS-entry import works in dev (where Vite serves merged CSS) but production splits chunks and only some of them lead with the manifest. Always configure both the entry import and the per-chunk PostCSS injector, and make the post-build assertion run against the production output specifically — never trust that dev behaviour predicts the shipped bundle.

The polyfill runs before the manifest and computes the wrong specificity

If you still polyfill for old browsers, ordering the PostCSS plugins wrong (postcss-cascade-layers before the manifest injector or before postcss-import) means the polyfill sees an incomplete layer set and emits specificity chains that do not match your real order. Keep the chain strictly: import inlining, then manifest injection, then polyfill.

Frequently Asked Questions

Why can't I just put the @layer declaration at the top of my main CSS file?

You can in a single-file build, but modern bundlers split CSS per entry and per async chunk. The chunk that loads first registers layer names first, and that order is non-deterministic across builds because it depends on the module graph and code-splitting heuristics. Injecting the manifest through the build tool guarantees the same declaration is prepended to every emitted CSS artifact, so the canonical order is registered before any rule regardless of which chunk the browser parses first.

Does injecting the manifest into every chunk duplicate the layer names?

Re-declaring the same @layer name statement is idempotent. The browser registers each name the first time it sees it and ignores later re-declarations of already-registered names, so prepending @layer reset, base, themes, components, utilities, overrides; to ten chunks costs a few bytes per chunk and produces no cascade side effects. It is far cheaper than a single mis-ordered layer registration.

How do I fail a CI build when someone adds an unlayered selector?

Run Stylelint with a cascade-layers plugin configured with a layer-name allowlist and require-defined-layers, then invoke it with --max-warnings 0 so any unlayered selector or unknown layer name becomes a non-zero exit code. Pair it with a small post-build script that parses the first @layer statement of each emitted bundle and asserts it equals the canonical order string.

Should the layer manifest live in a source file or be generated by the build?

Keep the canonical order in one source of truth — a single layers.css or a shared config constant — and let the build inject it. Hand-editing the declaration in multiple partials invites drift, and a generated manifest lets the same order feed both the PostCSS/Vite injection step and the Stylelint allowlist, so the enforcement and the emission never disagree.


Up: Architecture Patterns & Design System Scaling