Handling CSS and Asset Loading in Vite Remotes #
When a Vite federated remote mounts inside a host it built fine for, three things break in ways that never show up in standalone dev: the remote’s styles never appear (its code-split CSS is never injected), its images and fonts 404 (relative URLs resolve against the host origin), and the styles it does load leak out and restyle the host. This guide walks through bundling CSS with the exposed module, injecting it on mount, pinning asset URLs to the remote origin, and scoping styles so the boundary holds.
Prerequisites #
- Vite 5+ with
@originjs/vite-plugin-federation(or@module-federation/vite) already wired up. If you haven’t, start with Setting Up Vite with Federation Plugins. - A remote that
exposesat least one component, and a host that loads it via dynamicimport(). - The remote deployed to a known origin (e.g.
https://remote.example.com/) that differs from the host origin. - React 18+ for the examples; the principles apply unchanged to Vue or vanilla.
The mental model: in a single-app Vite build, the index.html it generates carries a <link> to the bundled CSS and a <base>/publicPath that makes assets resolve. A federated remote has no index.html in the host — the host loads remoteEntry.js only. So every guarantee that HTML gave you (CSS gets linked, asset URLs resolve, styles are scoped to that document) you now have to recreate explicitly inside the exposed module.
Step 1 — Make the exposed module import its own CSS #
The single most common cause of “styles missing after mount” is that the CSS was imported in main.tsx (the standalone entry), not in the exposed component. The host never runs main.tsx. If the exposed module doesn’t pull its styles in, they don’t exist in the federated graph.
Import the stylesheet from inside the exposed component (or a file it imports), not from the dev bootstrap.
// src/Widget.tsx ← this is what you expose
import styles from './Widget.module.css';
export default function Widget() {
return (
<div className={styles.root}>
<h2 className={styles.title}>Pricing</h2>
</div>
);
}
// vite.config.ts
import { defineConfig } from 'vite';
import federation from '@originjs/vite-plugin-federation';
export default defineConfig({
plugins: [
federation({
name: 'pricing_remote',
filename: 'remoteEntry.js',
exposes: { './Widget': './src/Widget.tsx' }, // exposes the file that imports the CSS
shared: ['react', 'react-dom'],
}),
],
build: { target: 'esnext' },
});
Because Widget.tsx imports the CSS, the federation build pulls that stylesheet into the chunk graph reachable from ./Widget. Keep your standalone main.tsx importing a global reset only — never put component styles there.
Step 2 — Get the CSS actually injected on mount #
By default Vite sets build.cssCodeSplit: true, which emits CSS as a separate .css file per JS chunk and relies on the generated HTML’s <link> to load it. The host has no such <link>, so a code-split remote can load JS with its styles sitting unrequested on the CDN.
You have two reliable options. The simplest is to disable CSS code splitting in the remote so Vite inlines styles via injected <style> tags that ship with the JS:
// vite.config.ts (remote)
export default defineConfig({
plugins: [/* federation(...) */],
build: {
target: 'esnext',
cssCodeSplit: false, // styles travel with the JS chunk and self-inject
},
});
If you need code-split CSS (large remotes, multiple exposes), inject the chunk’s stylesheet yourself when the component mounts, pointing the <link> at the remote origin (see Step 3 for where __REMOTE_ORIGIN__ comes from):
// src/useRemoteStylesheet.ts
import { useEffect } from 'react';
declare const __REMOTE_ORIGIN__: string;
export function useRemoteStylesheet(file: string) {
useEffect(() => {
const href = new URL(`assets/${file}`, __REMOTE_ORIGIN__).href;
if (document.querySelector(`link[data-mf="${href}"]`)) return;
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
link.dataset.mf = href;
document.head.appendChild(link);
}, [file]);
}
The data-mf guard makes injection idempotent so mounting two instances of the remote doesn’t append duplicate <link> tags.
Step 3 — Pin asset URLs to the remote origin #
This is the asset-404 fix. A background: url(./logo.png) or <img src={logo}> in the remote compiles to a URL that, at runtime, is resolved relative to the host’s document — so the browser fetches https://host.example.com/assets/logo.png, which doesn’t exist.
Set the remote’s base to its own absolute deployed origin so Vite bakes absolute URLs into the CSS and JS:
// vite.config.ts (remote)
const REMOTE_ORIGIN = 'https://remote.example.com/';
export default defineConfig({
base: REMOTE_ORIGIN, // every emitted asset URL becomes absolute to the remote
define: {
__REMOTE_ORIGIN__: JSON.stringify(REMOTE_ORIGIN),
},
plugins: [/* federation(...) */],
build: { target: 'esnext' },
});
For assets you reference programmatically (not through a bundler-rewritten import), build the URL from import.meta.url so it stays anchored to the chunk’s real location instead of the host page:
// resolves against the remote chunk, not the host document
const iconUrl = new URL('./assets/icon.svg', import.meta.url).href;
This import.meta.url form is the same mechanism that powers automatic publicPath configuration for remotes in Webpack — the bundler rewrites it to the chunk’s served location, which for a remote is its own origin.
Prefer a build-time env var over a hardcoded string so staging and production resolve correctly:
const REMOTE_ORIGIN = process.env.VITE_REMOTE_ORIGIN ?? 'http://localhost:5174/';
Step 4 — Scope styles so they don’t leak into the host #
Even correctly loaded styles are a problem if they’re global. A remote’s button { padding: 12px } will restyle every button in the host. Pick one isolation strategy and apply it consistently.
CSS Modules are the lightest option and require zero runtime. Vite treats any *.module.css file as scoped, hashing class names so they can’t collide:
/* Widget.module.css — classes become .Widget_root_a1b2c3 etc. */
.root {
font-family: 'Inter', system-ui, sans-serif;
color: #10324d;
}
.title {
font-size: 1.25rem;
}
Reference them as objects (styles.root) as in Step 1. Module CSS scopes class selectors; it does not scope bare element selectors, so avoid div { ... } rules in a module.
For total isolation — including element selectors and protection from the host’s global CSS reaching in — render the remote into a shadow root and inject its CSS there. This is the strongest boundary; the dedicated walkthrough is Shadow DOM Style Isolation for Micro-Frontends.
// ShadowMount.tsx — minimal shadow-root wrapper
import { useEffect, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import widgetCss from './Widget.module.css?inline'; // ?inline gives the raw text
import Widget from './Widget';
export default function ShadowMount() {
const hostEl = useRef<HTMLDivElement>(null);
useEffect(() => {
const shadow = hostEl.current!.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = widgetCss;
shadow.appendChild(style);
const mountPoint = document.createElement('div');
shadow.appendChild(mountPoint);
createRoot(mountPoint).render(<Widget />);
}, []);
return <div ref={hostEl} />;
}
The ?inline suffix tells Vite to give you the compiled CSS as a string instead of a side-effect injection, so you can drop it inside the shadow root where it’s sealed off from the host.
Step 5 — Fonts and other url() assets #
Fonts are just assets that break the same way and one extra way. The url() in @font-face must resolve to the remote origin (Step 3’s base handles this), and the font file must be served with permissive CORS, because cross-origin font requests are credentialed and the browser enforces Access-Control-Allow-Origin.
/* fonts.css imported by the exposed module */
@font-face {
font-family: 'Inter';
/* base config rewrites this to https://remote.example.com/assets/Inter-xxxx.woff2 */
src: url('./assets/Inter.woff2') format('woff2');
font-display: swap;
}
Serve the remote’s static assets with the CORS header so the host can pull the font:
Access-Control-Allow-Origin: *
# or, scoped to the host origin:
Access-Control-Allow-Origin: https://host.example.com
font-display: swap prevents an invisible-text flash while the cross-origin font downloads — important because a remote’s font has further to travel than a same-origin one.
Verification #
Mount the remote in the host, then confirm all three failure classes are closed.
- Styles render. With the remote mounted, the widget should look identical to its standalone build.
✓ DevTools Elements: a