Micro-Frontend Cascade Isolation with @layer
A micro-frontend platform composes HTML from teams that never build, review, or deploy together — and their CSS lands in one shared document, one shared cascade. This page, part of Architecture Patterns & Design System Scaling, shows how nested @layer namespaces turn that shared cascade into a set of contractually-ordered slots, so a rule shipped by the search team at 2pm can never silently overwrite a checkout button styled by another team last quarter — no Shadow DOM, no runtime scoping hacks, no !important arms race across repositories.
Concept: nested layers as micro-frontend namespaces
The CSS Cascading and Inheritance specification lets a layer contain other layers. A dotted name such as team-a.components addresses the components layer inside the team-a layer. The outer name (team-a) is what the host orders relative to other remotes; the inner name (components) is private detail that the remote controls. This two-level structure is exactly what a micro-frontend platform needs: the host fixes the order of teams, and each team keeps its own internal stack.
The minimal contract looks like this:
/* Host manifest — the ONE place the order of remotes is decided.
Loads before any remote bundle. Names registered here in first-seen
order; every later @layer block only fills a slot, never reorders. */
@layer reset, base, themes, team-a, team-b, utilities, overrides;/* Remote A's bundle — it only ever writes inside its own namespace.
'team-a.components' is a layer named 'components' nested in 'team-a'. */
@layer team-a.components {
/* :where() keeps specificity at 0-0-0 so ordering, not selector
weight, is the only thing that decides who wins. */
:where(.btn) { padding: 0.5rem 1rem; border-radius: 6px; }
}Because team-a and team-b are separate top-level layers ordered by the manifest, nothing Remote A writes under team-a.* can beat anything Remote B writes under team-b.* unless the manifest says team-a comes after team-b. The remotes never coordinate; the host’s single declaration is the whole agreement.
How the browser resolves nested, anonymous, and duplicate layers across bundles
Three registration rules drive everything on a composed page:
First-seen order wins. The browser registers each layer name the first time it appears, anywhere in the document, and never reorders it afterwards. This is why the host manifest must load first — it names team-a and team-b before either remote’s bundle does, locking their relative order. If Remote B’s <link> happens to arrive before Remote A’s at runtime, nothing changes: both names were already registered by the manifest.
Duplicate names merge. If two remotes both emit @layer team-a.components, the browser treats them as the same layer and merges their rules, which then resolve by specificity and source order. That is intended when it is genuinely the same team splitting a bundle — and a disaster when two different teams both picked the bare name components. The isolation guarantee depends entirely on top-level namespaces being unique per remote.
Anonymous layers are private and unaddressable. A block written as @layer { ... } (no name) creates a brand-new layer every time it is parsed; no other stylesheet can target it, add to it, or reorder it. That sounds like perfect isolation, but it is a trap for micro-frontends: an anonymous layer’s position is fixed by where it is first seen, so a lazily-loaded remote that ships anonymous layers gets an unpredictable slot relative to eagerly-loaded ones. Prefer explicit, host-declared namespaces over anonymous blocks for anything composed at runtime.
/* What the browser has registered after the manifest + both remotes load.
Left column = registration order (fixed); right = who wins a tie. */
@layer reset, base, themes, team-a, team-b, utilities, overrides;
/* team-a { components } and team-b { components } are DISTINCT layers.
A .btn rule in team-b.components beats one in team-a.components
because team-b is declared after team-a — specificity never consulted. */This registration model is the same one behind nested layers and inheritance; the micro-frontend twist is only that the nested blocks arrive in separately-deployed files rather than one stylesheet.
Practical isolation patterns
Pattern 1 — Per-remote nested layer
The default. Every remote owns exactly one top-level namespace and writes all of its CSS into sub-layers underneath it. The host never needs to know a remote’s internal structure — only its top-level name.
/* Remote: "search" — the whole bundle lives under one namespace.
Internal sub-layers (base, components, overrides) are the remote's
private business; the host only ordered 'search' in the manifest. */
@layer search.base {
:where(.results) { display: grid; gap: var(--space-2); }
}
@layer search.components {
:where(.result-card) { padding: var(--space-4); background: var(--color-surface); }
}
@layer search.overrides {
/* Even the remote's own escape hatch stays inside its namespace,
so it can never leak past 'search' into another team's slot. */
.search-empty :where(.result-card) { display: none; }
}The remote can restructure search.* freely across releases; as long as the top-level name stays search, its position relative to other remotes is untouched.
Pattern 2 — Shared host manifest
One host-owned stylesheet is the single source of truth for order. It declares foundational layers, every remote’s namespace, and the shared overrides slot — and it loads before anything else. This is the understanding layer declaration order rule applied at platform scale.
/* host/manifest.css — first stylesheet the shell injects.
Adding a new remote is a one-line change here, reviewed by the
platform team, not by any product team. */
@layer reset, base, themes, /* host foundations, shared by all */
team-a, team-b, checkout, /* remotes, ordered by the platform */
utilities, overrides; /* host-owned, always win last */Because foundations sit below every remote and overrides sits above, the host keeps two levers: shared tokens no remote can accidentally shadow, and a final override slot the platform can use to patch any remote in an incident without editing that remote’s code.
Pattern 3 — Module Federation layer contract
With Webpack Module Federation or Vite’s federation plugin, remotes are fetched at runtime and their CSS is injected as <style> tags whose order is not guaranteed. The contract that keeps this deterministic is: the host exposes the manifest, and each remote’s build wraps its output in its assigned namespace. Encode the namespace as shared config so a remote physically cannot emit the wrong name.
// remote's postcss.config.js — the wrapper is injected by the build,
// never hand-written, so an author cannot forget it or typo the name.
const REMOTE_LAYER = process.env.MFE_LAYER; // e.g. "checkout", set by CI
module.exports = {
plugins: [
// Wraps the ENTIRE bundle in `@layer <namespace> { ... }` so every
// rule this remote ships is quarantined under its top-level name.
require("postcss-wrap-in-layer")({ layer: REMOTE_LAYER }),
],
};Now the host manifest and the remote’s MFE_LAYER are the two halves of one contract. If they disagree, the remote’s rules land in an unregistered layer and visibly fail in review — a loud failure, not a silent collision. This composes cleanly with the same-repo techniques in component layer isolation; the only new constraint is that the wrapper is enforced by the build rather than trusted to authors.
Interaction with adjacent features
Micro-frontend isolation reuses machinery documented elsewhere on this site, with a few cross-bundle caveats:
Shared tokens still inherit through the DOM, not through layers. When the host declares --color-primary in its themes layer, every remote’s markup inherits that value down the DOM tree regardless of which team’s namespace styled the element. A remote does not need to re-import tokens; it just consumes the custom properties the host published. Layer order only decides which --color-primary declaration wins if two layers set it — and only the host should.
!important inverts across remotes too. An !important declaration in a lower remote’s namespace beats an !important in a higher one, mirroring the the role of !important in layers inversion. On a composed page this is doubly dangerous: a remote reaching for !important to win a local fight can invert priority against a completely unrelated team. Ban !important in remote bundles and let the manifest order settle every cross-team conflict.
Unlayered author styles from any remote outrank all namespaces. This is the single most common failure. Isolation holds only if every remote wraps all of its CSS. One unwrapped <style> tag injected by one remote’s runtime beats every other remote’s carefully-namespaced rules.
DevTools and Stylelint diagnostic workflow
Inspecting a composed document in Chrome DevTools
- Load the shell with every remote mounted — isolation bugs only appear when remotes coexist.
- Open DevTools → Elements → Styles and select an element owned by one remote (say a checkout button).
- Click the Cascade Layers view (Sources → Cascade layers, Chrome 107+) to see the full registered layer tree with each remote’s namespace and rule counts.
- Confirm the winning declaration sits under the expected namespace (e.g.
checkout.components) and not under(unlayered)or another team’s slot. - If a rule shows under
(unlayered), a remote shipped CSS outside@layer— that remote’s wrapper is missing or broke.
Enforcing the contract with Stylelint
Run this inside each remote’s own CI so a violation fails that team’s build, not the shell’s.
// stylelint.config.js — shipped as shared platform config each remote extends
export default {
plugins: ["@csstools/stylelint-plugin-cascade-layers"],
rules: {
// Every selector must live in a named @layer — no unlayered author styles
// are allowed to escape a remote and beat the whole platform.
"@csstools/cascade-layers/require-defined-layers": [true, {
// Each remote passes its OWN namespace here via env-templated config.
layerOrder: ["reset", "base", "themes", "search", "utilities", "overrides"]
}],
// Forbid !important outright: cross-remote inversion is never worth it.
"declaration-no-important": true
}
};Pair it with a CI script in the host repo that fails if two remotes register the same top-level namespace — the one collision Stylelint inside a single remote cannot see.
Migration checklist
Move an existing runtime-composed platform onto nested-layer isolation in this order:
- Inventory remotes and assign namespaces. List every independently-deployed remote and give each a unique top-level layer name (
team-a,checkout,search). Reserved, unique, one per remote. - Write the host manifest. Add a host-owned stylesheet that declares the full order once —
@layer reset, base, themes, <remotes…>, utilities, overrides;— and inject it before any remote loads. - Wrap each remote’s output. Add a PostCSS wrapper (Pattern 3) so every remote’s entire bundle is emitted inside
@layer <its-namespace> { … }. Authors never write the wrapper by hand. - Centralise tokens in the host. Move shared design tokens into the host’s
themeslayer; delete duplicated token definitions from remotes so they inherit one source. - Add lint enforcement. Ship a shared Stylelint config forbidding unlayered selectors and
!importantin remote bundles, run in each remote’s CI. - Verify the composed shell. Mount all remotes locally and confirm layer assignment in the DevTools Cascade Layers view — every rule under its own namespace, nothing unlayered.
- Guard against duplicate namespaces. Add a host CI check that fails the build if two remotes claim the same top-level name.
Edge cases and gotchas
Duplicate layer names collapse silently
Two teams that both pick the bare name components (not team-a.components) get their rules merged into one layer with no warning; the cascade then falls back to specificity and source order — the exact non-determinism isolation was meant to remove. There is no runtime error, only occasional visual breakage that depends on load order. Prevent it structurally: forbid bare, non-namespaced top-level layer names in remote bundles, and reject duplicate namespaces in CI.
/* WRONG — two different remotes both ship this. They MERGE. */
@layer components { :where(.btn) { border-radius: 0; } } /* remote A */
@layer components { :where(.btn) { border-radius: 999px; } } /* remote B */
/* Whoever loads last wins by source order — non-deterministic across deploys. */
/* RIGHT — distinct namespaces the host ordered. They stay separate. */
@layer team-a.components { :where(.btn) { border-radius: 0; } }
@layer team-b.components { :where(.btn) { border-radius: 999px; } }An unlayered remote wins everything
A remote that injects even one <style> block without an @layer wrapper — often via a runtime CSS-in-JS library or a third-party widget it embeds — produces unlayered author styles that outrank every namespace on the page, including the host’s overrides. The isolation model is only as strong as its weakest remote. Audit every style-injection path each remote uses, not just its static bundle, and treat a stray unlayered rule as a release blocker.
Lazy-loaded remotes and first-seen registration
If a remote is code-split and its CSS is fetched only when the user navigates to it, and its top-level name was not pre-declared in the host manifest, the name registers wherever it first appears at runtime — which can slot it above remotes that loaded earlier. Always pre-name every possible remote in the manifest, even ones loaded lazily, so their position is fixed before they arrive.
Nested @import layer() inside a remote
A remote may import its own third-party CSS with @import url(widget.css) layer(vendor). That vendor layer is registered at the document level, not inside the remote’s namespace — so two remotes each importing into a bare vendor layer will merge their vendors. If a remote must sandbox its own dependencies, nest the import target under its namespace conceptually by using a unique name like checkout-vendor, and order it in the host manifest.
FAQ
Do micro-frontends need Shadow DOM if they use cascade layers?
Not for cascade ordering. Shadow DOM gives you selector-scoping (a remote’s .btn cannot match another remote’s markup) plus cascade isolation, but it also blocks shared design tokens and complicates SSR. Nested @layer namespaces solve the cascade-ordering half — a rule in team-a.components can never beat a rule in team-b.components by accident, because the host manifest fixes their relative order — while keeping a single global token layer that every remote inherits. Many teams use layers alone and reserve Shadow DOM for genuinely untrusted third-party widgets.
What happens when two independently-shipped remotes both declare @layer components?
If both write the bare name components with no namespace, their rules collapse into one shared layer and resolve by specificity and source order — exactly the collision you are trying to avoid. The fix is to give each remote a distinct nested namespace, e.g. @layer team-a.components and @layer team-b.components. Duplicate layer names are merged by the browser; distinct namespaced names are kept separate and ordered by the host manifest. The preventing style bleed between micro-frontends guide shows the per-remote wrapper that enforces this.
How do I control the order of remotes I do not build together?
Declare the order in a host-owned manifest stylesheet that loads first: @layer reset, base, themes, team-a, team-b, utilities, overrides;. Because the browser registers layer names in first-seen order, naming every remote’s top-level namespace in that single statement fixes their precedence before any remote bundle arrives. Remotes then only ever write into their own sub-layer, and their load order at runtime becomes irrelevant.
Does a remote's unlayered CSS still break isolation under this model?
Yes. Any rule a remote ships outside an @layer block is an unlayered author style, and unlayered styles beat every named layer including your overrides. A single unwrapped utility class from one remote will win over every other remote’s layered components. Enforce a build-time rule (a Stylelint plugin or a PostCSS wrapper) that no remote can emit unlayered CSS, and audit the composed document in DevTools’ Cascade Layers view.
Related
- Preventing Style Bleed Between Micro-Frontends — the step-by-step build recipe for assigning each remote its own nested layer under a host manifest
- Component Layer Isolation — the same-repo foundation these cross-repo patterns extend
- Nested Layers and Inheritance — how dotted layer names register and resolve at the spec level
- Understanding Layer Declaration Order — why the host manifest’s first-seen declaration is the single source of truth
- The Role of !important in Layers — why
!importantinverts priority and is doubly dangerous across remotes