Assigning Layer Namespaces per Micro-Frontend
In a micro-frontend architecture, several independently deployed applications write CSS into one document and none of them knows what the others declared. Without a namespace scheme the first app to inject a rule decides a layer position for everybody, and that position changes with load order — which changes with network timing. A per-app namespace under a shell-owned band removes the nondeterminism. This extends micro-frontend cascade isolation in the architecture patterns reference.
Prerequisites
A shell (host) application that loads remotes, and a shared contract package the shell and every remote depend on. Familiarity with layer naming conventions and governance helps, because this is that problem without a shared build.
Step 1: Reserve one band for all remotes
/* shell/src/styles/layers.css — parsed before any remote loads.
WHY a single `apps` band: it guarantees no remote can occupy a top-level
position, so the shell's design system, utilities and overrides keep their
relationship to every app regardless of how many apps exist. */
@layer reset, vendor, base, theme, ds, apps, utilities, overrides;What this does: turns “N apps competing for position” into “N children of one reviewed band”. Adding a tenth app cannot change where the design system sits relative to utilities.
Step 2: Derive the namespace from the registered app id
// packages/mfe-contract/src/layer.js
// WHY derive rather than let each team choose: two teams independently
// picking `app` or `main` is a collision that produces silent cross-app
// overrides, and nothing in the browser reports it.
export const APPS_BAND = 'apps';
export function layerNameFor(appId) {
if (!/^[a-z][a-z0-9-]*$/.test(appId)) {
throw new Error(`invalid app id "${appId}" — lowercase kebab-case only`);
}
return `${APPS_BAND}.${appId}`;
}What this does: makes the namespace a function of the registry rather than a convention. The validation is worth keeping: an app id with an underscore or a capital produces a layer name that fails the shell’s lint pattern and is confusing to read in DevTools.
Step 3: Declare the sibling order in the shell
/* shell/src/styles/layers.css — the shell owns this list.
WHY here and not in each remote: remotes deploy independently, so if each
declared its own position the resolved order would depend on which bundle
the network delivered first. Declaring all siblings up front makes the
result identical on every load. */
@layer apps.catalog, apps.search, apps.checkout, apps.account;What this does: fixes the relationships between apps. The order encodes a real decision — later siblings win — so it belongs in a reviewed file rather than emerging from load timing.
Step 4: Have each remote emit its own namespace
// apps/checkout/vite.config.js
// WHY at build time in the remote: the shell cannot reliably wrap CSS it
// receives through a JS module, and wrapping after injection always leaves a
// window where the unlayered rules are live.
import { layerNameFor } from '@org/mfe-contract';
const LAYER = layerNameFor('checkout'); // → "apps.checkout"
export default {
css: {
postcss: {
plugins: [
{
postcssPlugin: 'wrap-in-layer',
Once(root) {
// WHY skip if already wrapped: hot reload can run this twice,
// which would produce apps.checkout.apps.checkout.
if (root.first?.type === 'atrule' && root.first.name === 'layer') return;
const layer = root.clone().removeAll();
root.each((node) => layer.append(node.clone()));
root.removeAll();
root.append({ name: 'layer', params: LAYER, nodes: layer.nodes });
},
},
],
},
},
};What this does: makes each remote responsible for its own namespace, which is the only arrangement that survives independent deployment.
Step 5: Assert the namespace at mount
// packages/mfe-contract/src/assert-layer.js
// WHY development-only: the check walks every stylesheet, which is too
// expensive for production, and the failure it catches is a build
// misconfiguration that should never reach a user.
export function assertAppStyles(appId) {
if (process.env.NODE_ENV === 'production') return;
const expected = `apps.${appId}`;
const found = new Set();
const walk = (rules, path = []) => {
for (const r of rules) {
if (r.constructor.name === 'CSSLayerBlockRule') {
const next = r.name ? [...path, r.name] : path;
found.add(next.join('.'));
walk(r.cssRules, next);
} else if (r.cssRules) {
walk(r.cssRules, path);
} else if (r.selectorText && path.length === 0) {
found.add('(unlayered)');
}
}
};
for (const s of document.styleSheets) {
try { walk(s.cssRules); } catch { /* cross-origin */ }
}
if (found.has('(unlayered)')) {
console.error(`[${appId}] unlayered CSS detected — it outranks every layer`);
}
if (!found.has(expected)) {
console.warn(`[${appId}] expected layer ${expected} was never registered`);
}
}What this does: surfaces the two failures that are otherwise invisible — a remote shipping unlayered CSS, and a remote whose wrapper silently stopped running.
Verification
- Load the shell with every remote and read the registered order — it must match the shell’s declaration exactly, and must not change between reloads.
- Load the remotes in a different order (throttle one) and confirm the resolved order is unchanged. That is the property the whole scheme exists to provide.
- Confirm each app’s rules carry the expected annotation in DevTools:
@layer apps.checkout. - Deliberately ship a remote with the wrapper disabled and confirm the mount-time assertion fires in development.
Troubleshooting
Two apps’ styles still conflict.
: Expected — layers order, they do not scope. If one app’s button { } rule is hitting another app’s markup, the fix is a scoping mechanism or a class prefix, not a layer change.
An app’s layer appears at the top level rather than under apps.
: Its build wrapped the CSS in checkout instead of apps.checkout, so a new top-level layer was created at the end of the stack. Check the contract package version the remote is building against.
Order changes between page loads. : The shell’s sibling declaration is missing or is being parsed after a remote’s CSS. It must be in the shell’s own stylesheet, loaded before any remote bundle.
A remote’s styles vanish entirely.
: Double wrapping — apps.checkout.apps.checkout — usually from a hot-reload run of the PostCSS plugin. Guard on the existing wrapper as in the example.
The shell cannot override an app.
: apps sits below utilities and overrides, so the shell can override any app from those bands. If it cannot, the app is injecting unlayered CSS at runtime and the mount-time assertion will say so.
Complete working example
// packages/mfe-contract/src/index.js — the shared contract, one small file.
/** WHY a single reserved band: no remote can ever take a top-level slot,
* so the shell's own layers keep their relationships as apps come and go. */
export const APPS_BAND = 'apps';
/** The registry is the source of truth for both the namespace and the
* sibling order. Adding an app is a change to THIS file, reviewed by the
* platform team, and it must ship before the app deploys. */
export const APP_ORDER = ['catalog', 'search', 'checkout', 'account'];
export function layerNameFor(appId) {
if (!APP_ORDER.includes(appId)) {
throw new Error(`"${appId}" is not registered — add it to APP_ORDER first`);
}
return `${APPS_BAND}.${appId}`;
}
/** WHY generated rather than hand-written: the shell's declaration and the
* registry cannot drift apart if one is derived from the other. */
export function appsLayerStatement() {
return `@layer ${APP_ORDER.map((id) => `${APPS_BAND}.${id}`).join(', ')};`;
}/* shell/src/styles/layers.css — generated at build time from the contract.
WHY the shell owns both statements: remotes deploy independently, so the
only file guaranteed to be parsed before all of them is this one. */
@layer reset, vendor, base, theme, ds, apps, utilities, overrides;
@layer apps.catalog, apps.search, apps.checkout, apps.account;
@import url("./reset.css") layer(reset);
@import url("./base.css") layer(base);
@import url("./theme.css") layer(theme);
@import url("@org/design-system/dist/index.css") layer(ds);
@import url("./utilities.css") layer(utilities);// apps/checkout/src/bootstrap.js — every remote does exactly this.
import { assertAppStyles } from '@org/mfe-contract/assert-layer';
import './styles/index.css'; // already wrapped in apps.checkout at build time
export function mount(el) {
// WHY assert at mount rather than at import: stylesheets injected by the
// bundler are present by then, so the check sees the real state.
assertAppStyles('checkout');
// …render…
}Frequently Asked Questions
Do cascade layers isolate micro-frontends from each other?
They order declarations; they do not scope selectors. A button { } rule inside apps.checkout still matches every button in the document, including ones rendered by another application. What the namespace gives you is a deterministic and inspectable resolution when that happens — you can see which app won and why. Preventing the match in the first place needs Shadow DOM, a scoping construct, or a disciplined class prefix.
Who decides the order of the per-app sub-layers?
The shell, exclusively. Remotes deploy on independent schedules, so if each declared its own position the resolved order would depend on which bundle the network happened to deliver first — a difference that shows up as an intermittent, unreproducible styling bug. Declaring every sibling in the host up front makes the outcome identical on every load, whatever the timing.
What happens when a new micro-frontend is added?
Its namespace has to be registered in the contract and declared by the shell before the app can deploy safely. Without that, the app’s first injected rule creates the layer implicitly at the end of the apps band — usually harmless, but its position then depends on which app mounted first, which is exactly the nondeterminism the scheme is designed to remove. Make the contract change a prerequisite in the onboarding checklist.
Related
- Micro-Frontend Cascade Isolation — the wider isolation strategy
- Coordinating Layer Order Across Independently Deployed Apps — versioning the contract these namespaces live in
- Preventing Style Bleed Between Micro-Frontends — the scoping half that layers do not cover
- Layer Naming Conventions and Governance — the same problem inside a single build
Up: Micro-Frontend Cascade Isolation → Architecture Patterns & Design System Scaling