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.

One reserved band, one sub-layer per application The shell declares reset, vendor, base, theme, design system, apps, utilities and overrides. The apps band is expanded to the right showing four sub-layers, one for each remote application, whose sibling order is fixed by the shell. Shell-owned stack reset · vendor · base theme ds — the shared design system apps — every remote nests here utilities overrides apps.* — sibling order set by the shell apps.catalog apps.search apps.checkout apps.account Deterministic regardless of which remote's bundle arrives first. A tenth app changes nothing above or below the band.

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.

What layer namespaces give you, and what they do not Two columns. Layers provide predictable ordering, a deterministic winner, and independent deployability. They do not provide selector scoping, protection from a global element selector, or isolation of an app's rules from another app's DOM. what namespaces DO give you what they do NOT a deterministic winner in any conflict selector scoping — rules still match order independent of load timing protection from a global `button {}` rule a reviewable per-app boundary isolation from another app's DOM independent deployment without a freeze defence against unlayered injected CSS a name in DevTools that identifies the owner encapsulation — that needs Shadow DOM Layers make conflicts predictable and resolvable. Preventing them entirely is a different tool.

Verification

  1. 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.
  2. 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.
  3. Confirm each app’s rules carry the expected annotation in DevTools: @layer apps.checkout.
  4. Deliberately ship a remote with the wrapper disabled and confirm the mount-time assertion fires in development.
Three onboarding steps for a new application Three prerequisites before a new micro-frontend can deploy safely: its identifier registered in the contract, its sibling position declared by the shell, and its build wrapping its own CSS. the app id is registered in the contract the namespace is derived from it, not chosen the shell declares its sibling position otherwise the position depends on load timing the build wraps its own CSS the shell cannot reliably wrap what it receives

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.

Up: Micro-Frontend Cascade IsolationArchitecture Patterns & Design System Scaling