A production-ready Microfrontend boilerplate using Vite Module Federation with a React host, React remote, and Vue remote — all in TypeScript.
| App | Framework | Port |
|---|---|---|
host-app |
React + TypeScript + Vite | 5175 |
react-app |
React + TypeScript + Vite | 5176 |
vue-app |
Vue 3 + TypeScript + Vite | 5177 |
Shared tooling: Tailwind CSS v4, @originjs/vite-plugin-federation, pnpm workspaces
microfrontend-boilerplate/
├── apps/
│ ├── host-app/ # Shell — routing, layout, auth
│ │ └── src/
│ │ ├── layouts/
│ │ │ └── CmsLayout.tsx # Sidebar + Header + main content
│ │ ├── components/
│ │ │ ├── Sidebar.tsx
│ │ │ ├── Header.tsx
│ │ │ └── RemoteWrapper.tsx # Generic remote loader
│ │ ├── pages/
│ │ │ ├── DashboardPage.tsx
│ │ │ ├── ReactPage.tsx # Mounts react-app remote
│ │ │ └── VuePage.tsx # Mounts vue-app remote
│ │ ├── router.tsx
│ │ ├── declarations.d.ts # Remote module types
│ │ └── main.tsx
│ ├── react-app/ # React remote — owns its own domain + routing
│ │ └── src/
│ │ └── mount.tsx # Entry point exposed via federation
│ └── vue-app/ # Vue remote — owns its own domain + routing
│ └── src/
│ └── mount.ts # Entry point exposed via federation
└── packages/ # Shared packages (api, store, ui, tailwind-config)
Important:
@originjs/vite-plugin-federationonly works with built output, notvite dev. All apps must be built and served viavite preview.
# From root
pnpm install
# Or per app
cd apps/react-app && npm install
cd apps/vue-app && npm install
cd apps/host-app && npm install# Terminal 1 — React remote
cd apps/react-app
npm run build && npm run preview
# Terminal 2 — Vue remote
cd apps/vue-app
npm run build && npm run preview# Terminal 3
cd apps/host-app
npm run build && npm run previewhttp://localhost:5175
Routing lives entirely in host-app. Each remote owns its own internal sub-routes.
host-app router
├── / → DashboardPage (host-owned)
├── /react-app/* → ReactPage (mounts react-app remote)
└── /vue-app/* → VuePage (mounts vue-app remote)
Each remote handles its own sub-routes independently. The host only knows the top-level prefix.
react-app uses createBrowserRouter with basename:
// react-app/src/mount.tsx
export function mount(el: HTMLElement, basePath = "/react-app") {
const router = createBrowserRouter([
{ path: "/", element: <List /> },
{ path: "/create", element: <Form mode="create" /> },
{ path: "/:id", element: <Detail /> },
{ path: "/edit/:id", element: <Form mode="edit" /> },
], { basename: basePath });
ReactDOM.createRoot(el).render(<RouterProvider router={router} />);
}vue-app uses createWebHistory with base:
// vue-app/src/mount.ts
export function mount(el: HTMLElement, basePath = "/vue-app") {
const router = createRouter({
history: createWebHistory(basePath),
routes: [
{ path: "/", component: List },
{ path: "/create", component: Form, props: { mode: "create" } },
{ path: "/:id", component: Detail },
{ path: "/edit/:id", component: Form, props: { mode: "edit" } },
],
});
const app = createApp(App);
app.use(router);
app.mount(el);
return () => app.unmount();
}npm create vite@latest new-app -- --template react-tsfederation({
name: "newApp",
filename: "remoteEntry.js",
exposes: { "./mount": "./src/mount.tsx" },
shared: { react: { singleton: true }, "react-dom": { singleton: true } },
})remotes: {
reactApp: "http://localhost:5176/assets/remoteEntry.js",
vueApp: "http://localhost:5177/assets/remoteEntry.js",
newApp: "http://localhost:5178/assets/remoteEntry.js", // add this
}declare module "newApp/mount" {
export function mount(el: HTMLElement, basePath?: string): void;
}// router.tsx
{ path: "new-app/*", element: <NewAppPage /> }// pages/NewAppPage.tsx
export default function NewAppPage() {
return (
<RemoteWrapper
name="NewApp"
loader={() => import("newApp/mount") as any}
basePath="/new-app"
/>
);
}{ to: "/new-app", label: "New App", icon: <YourIcon /> }Each app runs its own Tailwind instance. They are independent and do not share styles at runtime.
/* Each app's entry CSS */
@import "tailwindcss";Tailwind v4 syntax — no tailwind.config.js needed. The @tailwindcss/vite plugin auto-detects content.
| App | Entry CSS |
|---|---|
host-app |
src/index.css |
react-app |
src/App.css |
vue-app |
src/style.css |
Apps must never import directly from each other. Communication goes through:
Custom Events → fire-and-forget, framework-agnostic
URL / Query Params → state that needs to be shareable/bookmarkable
packages/store → shared global state (auth, theme, user)
packages/api → shared HTTP client with auth interceptors
// vue-app: emit
window.dispatchEvent(new CustomEvent("mfe:order-created", {
detail: { orderId: "123" }
}));
// react-app: listen
useEffect(() => {
const handler = (e: Event) => {
const { orderId } = (e as CustomEvent).detail;
// handle event
};
window.addEventListener("mfe:order-created", handler);
return () => window.removeEventListener("mfe:order-created", handler);
}, []);Located in packages/ and consumed by any app via workspace imports.
| Package | Purpose |
|---|---|
@mfe/api |
Axios instance with auth interceptors |
@mfe/store |
Global state via Custom Events |
@mfe/ui |
Framework-agnostic Web Components |
@mfe/tailwind-config |
Shared Tailwind base config |
Install in an app:
pnpm add @mfe/api --filter react-appFederation sometimes wraps exports. RemoteWrapper handles this automatically:
const mountFn = mod.mount ?? mod.default?.mount ?? mod.default;- Ensure
@import "tailwindcss"(v4 syntax) is in each app's entry CSS — not the v3@tailwinddirectives. - Ensure the CSS file is imported in the entry point (
main.tsx/mount.tsx).
Remove hardcoded versions from shared config — let federation auto-detect from package.json:
// ✅
shared: { react: { singleton: true } } as any
// ❌
shared: { react: { singleton: true, requiredVersion: "^18.0.0" } }Add an augmentation file at the project root:
// federation.d.ts
declare module "@originjs/vite-plugin-federation" {
interface SharedConfig {
singleton?: boolean;
requiredVersion?: string;
eager?: boolean;
}
}- Federation is for UI — use
packages/for shared logic, never share logic via federation - Always build before preview —
vite devdoes not support module federation - Remotes are independent — each remote can be deployed without redeploying the host
- Host owns top-level routing — remotes own everything under their prefix
- No direct cross-remote imports — communicate only via Custom Events or URL