Build a React Microfrontend Shell
Build a React shell that loads federated microfrontends from an API registry.
Introduction
30 Second Summary
Modern web products often feel like one continuous experience even when different teams own different sections. A slow or broken section should not make the entire page disappear.
In this project, you will build a React root shell that discovers independently served microfrontends from an Express API. It keeps its own layout visible while Module Federation loads a Catalog page at runtime.
What You'll Build
You'll select Catalog in your browser to see its product cards load beneath the persistent Root Shell header.
By the end of this project, you'll have:
- A persistent shell frame that keeps its branding visible while each remote page changes.
- API-driven navigation that reflects the backend registry without rebuilding the shell.
- An independently served Catalog page that runs alone or inside the shell with loading and failure states protecting the frame.
- Secret Mission: Publish a second Deals page through the existing producer contract so a registry change makes it appear without any shell edit.
Are there any prerequisites?
You should be comfortable with React and TypeScript. You also need Node.js, npm, Git, and Visual Studio Code installed on Windows.
The setup step confirms whether your installed Node.js version meets the project requirements.
Before We Start
Before any hands-on work, this step locks in the ownership boundary for the API-driven microfrontend root shell you are committing to build. The shell owns persistent branding, navigation, layout, loading states, and error states, while independently served microfrontends own their exposed page interfaces.
Set Up the Three-App Workspace
Your shell and remote apps run as separate processes. Version drift can hide the architecture behind runtime errors or bundler differences.
In this step, you verify Node.js and npm. You then create a pinned React workspace with TypeScript in Visual Studio Code.
Pinned dependencies keep Rsbuild behavior consistent across the three apps. You finish by serving a temporary root shell in the browser.
In this step, get ready to:
- Verify compatible Node.js and npm versions.
- Create the three-app workspace with pinned dependencies.
- Launch the temporary shell with Rsbuild.
Verify tools and create the workspace
Rsbuild v2 requires Node.js 20.19 or later. Node.js 22 must be at least 22.12.
You will use PowerShell to check both tools before creating any files. This catches an incompatible runtime before dependency installation begins.
- Press the Windows key to open Windows search.
- Type PowerShell into the search field.
- Press Enter to start PowerShell.
- Check your Node.js and npm versions by running these commands:
node --version
npm --version
What do these commands show?
- The first command prints the installed Node.js version.
- The second command confirms that npm is available.
- A compatible result uses Node.js 20.19 or later. Node.js 22 requires 22.12 or later.
Choose the tab that matches the output in PowerShell.
✔️ I see a compatible version
Your local runtime meets the Rsbuild requirement. The workspace can now use every pinned package without a Node.js compatibility gap.
- Keep PowerShell open for the workspace commands below.
ⓧ I see an older version
Your installed Node.js version is below the project requirement. Update it before installing the workspace dependencies.
The Windows installer uses a setup wizard. Keep its default selections so the updated runtime remains available from PowerShell.
- Download a compatible Windows installer from the official Node.js download page.
- Run the downloaded Windows installer.
- Keep the default selections in the setup wizard.
- Restart PowerShell after the installer finishes.
- Recheck both versions by running these commands:
node --version
npm --version
What confirms the update?
The first line should now meet the Node.js requirement. The second line should still show an npm version.
ⓧ Command not found
PowerShell cannot currently access Node.js from its command path. Reinstalling Node.js restores both the runtime and npm.
The installer presents a setup wizard. Keep its default selections so PowerShell can locate the installed commands.
- Download a compatible Windows installer from the official Node.js download page.
- Run the downloaded Windows installer.
- Keep the default selections in the setup wizard.
- Restart PowerShell after the installer finishes.
- Verify both commands by running:
node --version
npm --version
What confirms the installation?
PowerShell should print one version for Node.js. It should print another version for npm.
The workspace uses one repository with separate source folders for the shell, Catalog producer, and registry API. This keeps the processes independent while sharing pinned tooling.
The next command opens the repository in Visual Studio Code. You may see a Workspace Trust prompt because this is the first time the folder has been opened.
- Create the repository on your Desktop by running these commands:
Set-Location -Path $HOME\Desktop
New-Item -ItemType Directory -Path microfrontend-root-shell\apps\shell\src -Force
New-Item -ItemType Directory -Path microfrontend-root-shell\apps\catalog\src -Force
New-Item -ItemType Directory -Path microfrontend-root-shell\apps\registry\src -Force
Set-Location -Path microfrontend-root-shell -PassThru
code .
What do these commands create?
- PowerShell first moves to your Desktop.
- The three directory commands create the shell, Catalog, and registry source folders.
- The location command moves PowerShell into microfrontend-root-shell.
- The final command opens that repository as the Visual Studio Code workspace.
- Review the folder name if the Workspace Trust prompt appears.
- Select Yes, I trust the authors because you created this empty workspace.
You should see apps/shell/src, apps/catalog/src, and apps/registry/src in the Explorer sidebar.
Workspace folders missing?
- Check that PowerShell started the command from your Desktop.
- Expand each folder in the Explorer sidebar to reveal its src folder.
- If Visual Studio Code did not open, confirm its command-line launcher is available in PowerShell.
- Help me troubleshoot my PowerShell workspace commands.
Create the pinned workspace
The root package.json gives all three processes one dependency set. Exact pins prevent package updates from changing the architecture while you build it.
- Select microfrontend-root-shell in the Explorer sidebar.
- Use the new-file control to create package.json inside microfrontend-root-shell.
An empty package.json tab should now be open in the editor.
- Populate package.json by pasting this configuration:
{
"name": "microfrontend-root-shell",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev:shell": "cd apps/shell && rsbuild dev",
"dev:catalog": "cd apps/catalog && rsbuild dev",
"dev:registry": "tsx apps/registry/src/server.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@module-federation/enhanced": "2.9.2",
"cors": "2.8.6",
"express": "5.3.0",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"@module-federation/rsbuild-plugin": "2.9.2",
"@rsbuild/core": "2.2.13",
"@rsbuild/plugin-react": "2.1.1",
"@types/cors": "2.8.19",
"@types/express": "5.0.6",
"@types/node": "26.6.5",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"tsx": "4.23.15",
"typescript": "7.0.2"
}
}
What does this configuration control?
- The scripts start each local process from the repository root.
- The runtime dependencies provide React, Express, CORS, and Module Federation.
- The development dependencies provide Rsbuild, TypeScript, tsx, and the matching type packages.
- The private setting prevents accidental publication of the repository package.
- Save package.json by pressing Ctrl+S.
The saved package.json should remain listed at the top level of the Explorer sidebar.
Seeing a JSON warning?
- Check every property except the final property in each object for a trailing comma.
- Confirm each package name and version matches the code block.
- Help me find the invalid JSON in my package file.
The root tsconfig.json applies the same compiler rules to every TypeScript file under apps. Strict checking catches contract mismatches between the registry and shell.
- Select microfrontend-root-shell in the Explorer sidebar.
- Use the new-file control to create tsconfig.json inside microfrontend-root-shell.
An empty tsconfig.json tab should now be open in the editor.
- Populate tsconfig.json by pasting this configuration:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["apps/**/*.ts", "apps/**/*.tsx"]
}
What does this TypeScript configuration do?
- The target and library settings enable modern JavaScript plus browser APIs.
- Bundler module resolution matches how Rsbuild resolves imports.
- The JSX setting enables React syntax inside .tsx files.
- Strict checking validates every TypeScript source file without emitting compiled files.
- Save tsconfig.json by pressing Ctrl+S.
The repository root should now show both package.json and tsconfig.json.
TypeScript settings underlined?
- Confirm compilerOptions contains every setting from the code block.
- Check that the two include patterns use forward slashes.
- Help me correct my root TypeScript configuration.
Dependency installation reads the exact pins from package.json. The first install can take a few minutes while npm downloads the workspace packages.
- Open the integrated terminal by pressing Ctrl+`.
- Install the pinned dependencies by running:
npm install
What does this command change?
npm reads every pinned dependency from package.json. It installs those packages for all three applications in the root workspace.
PowerShell should return to the prompt without npm reporting an error. You should also see the installed dependency folder and lock file at the repository root.
Dependency installation failed?
- Confirm the terminal path ends with microfrontend-root-shell.
- Check package.json for a JSON warning before retrying the installation.
- Verify the Node.js version still meets the Rsbuild requirement.
- Help me troubleshoot my pinned npm installation.
Launch the starter shell
The shell needs a development-server configuration before it can render React. This first configuration enables React compilation and fixes the shell address to port 2000.
- Select apps/shell in the Explorer sidebar.
- Use the new-file control to create rsbuild.config.ts inside apps/shell.
An empty apps/shell/rsbuild.config.ts tab should now be open in the editor.
- Configure the shell development server by pasting:
import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';
export default defineConfig({
server: {
port: 2000,
},
plugins: [
pluginReact(),
],
});
What does this configuration do?
- The React plugin compiles JSX from the shell entry point.
- The server setting keeps the shell at http://localhost:2000 throughout the project.
- The exported configuration becomes the input for the shell's Rsbuild development command.
- Save apps/shell/rsbuild.config.ts by pressing Ctrl+S.
The saved configuration should appear directly under apps/shell.
Configuration imports underlined?
- Confirm the dependency installation finished before reviewing the imports.
- Check that both package names match the code block.
- Help me fix my Rsbuild configuration imports.
Rsbuild detects src/index.tsx as the shell entry point. A temporary component gives you an immediate browser result before the full layout arrives in the next step.
- Select apps/shell/src in the Explorer sidebar.
- Use the new-file control to create index.tsx inside apps/shell/src.
An empty apps/shell/src/index.tsx tab should now be open in the editor.
- Add the temporary shell entry point by pasting:
import { createRoot } from 'react-dom/client';
const container = document.getElementById('root');
if (!container) {
throw new Error('Root element not found');
}
export default function App() {
return (
<h1>Root Shell</h1>
);
}
createRoot(container).render(<App />);
What does this starter do?
- The container lookup finds the page element where React renders the shell.
- The guard stops execution if that element is unavailable.
- The temporary component renders the Root Shell heading.
- The final line mounts the component through React's client root.
- Save apps/shell/src/index.tsx by pressing Ctrl+S.
Before you start the server, what do you expect the browser to show at the shell address?
- Start the shell development server from the repository root by running:
npm run dev:shell
What does this command start?
The root script moves into apps/shell and starts the Rsbuild development server. The terminal stays attached to that server while it is running.
Keep this terminal open during development. Press Ctrl+C when you need to stop the server.
- Enter http://localhost:2000 in your browser address bar.
You should see the Root Shell heading in the browser. That first visible page confirms that React, TypeScript, Rsbuild, and the pinned workspace are working together.
That setup is complete. Your shell development server is now serving the starter entry point from the three-app repository.
Starter shell not loading?
- Confirm the terminal still shows the running shell process.
- Check that apps/shell/src/index.tsx is saved.
- Verify that apps/shell/rsbuild.config.ts uses port 2000.
- Help me troubleshoot my starter Rsbuild shell.
✔️ Awesome, I've got everything!
Your pinned workspace is installed. The temporary root shell is now running at the expected local address.
- Keep the shell development server running for the next step.
ⓧ I'd like to double check the full code
Compare each saved file with the complete workspace code below.
{
"name": "microfrontend-root-shell",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev:shell": "cd apps/shell && rsbuild dev",
"dev:catalog": "cd apps/catalog && rsbuild dev",
"dev:registry": "tsx apps/registry/src/server.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@module-federation/enhanced": "2.9.2",
"cors": "2.8.6",
"express": "5.3.0",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"@module-federation/rsbuild-plugin": "2.9.2",
"@rsbuild/core": "2.2.13",
"@rsbuild/plugin-react": "2.1.1",
"@types/cors": "2.8.19",
"@types/express": "5.0.6",
"@types/node": "26.6.5",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"tsx": "4.23.15",
"typescript": "7.0.2"
}
}
Package checkpoint
This file contains the exact scripts and dependency pins used by the three-app workspace.
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["apps/**/*.ts", "apps/**/*.tsx"]
}
TypeScript checkpoint
This file applies modern browser libraries plus strict checking to TypeScript files under apps.
import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';
export default defineConfig({
server: {
port: 2000,
},
plugins: [
pluginReact(),
],
});
Rsbuild checkpoint
This file enables React compilation and fixes the shell development server to port 2000.
import { createRoot } from 'react-dom/client';
const container = document.getElementById('root');
if (!container) {
throw new Error('Root element not found');
}
export default function App() {
return (
<h1>Root Shell</h1>
);
}
createRoot(container).render(<App />);
Starter entry checkpoint
This temporary entry point mounts React into the root element and renders the Root Shell heading.
Your pinned workspace is ready across three application folders. Next, you will replace the temporary heading with the persistent shell layout and its first Catalog navigation item.
Build the Visible Root Shell
Your workspace already serves a starter React page through Rsbuild. That result proves the toolchain is ready for the first visible slice of your platform.
A root shell keeps its frame visible while application content changes. This step uses one hard-coded Catalog record to expose the maintenance cost of storing application definitions inside shell source.
In this step, get ready to:
- Build a persistent header and application sidebar.
- Add a temporary hard-coded Catalog definition with selected-item state.
- Style the shell and verify its placeholder content.
Create the shell component
The shell owns the interface that must survive every application change. Its header and navigation therefore sit outside the selected application's content panel.
- Create App.tsx inside apps/shell/src by selecting New File in the Visual Studio Code Explorer toolbar.
- Build the shell-owned frame by pasting this code into App.tsx:
import { useState } from 'react';
// Keep discovery temporary so its maintenance cost is visible.
const remotes = [
{
id: 'catalog',
label: 'Catalog',
remoteName: 'catalog',
entry: 'http://localhost:3001/mf-manifest.json',
exposedModule: 'App',
},
];
export default function App() {
// Track which hard-coded application owns the content panel.
const [selectedId, setSelectedId] = useState('catalog');
const selectedRemote = remotes.find((remote) => remote.id === selectedId);
return (
<div className="shell">
<header className="shell-header"><div><span className="eyebrow">Microfrontend Platform</span><h1>Root Shell</h1></div><span className="status status-loading">loading</span></header>
<div className="shell-body">
<aside className="sidebar"><h2>Applications</h2><nav aria-label="Microfrontends">{remotes.map((remote) => <button className={remote.id === selectedId ? 'nav-item active' : 'nav-item'} key={remote.id} onClick={() => setSelectedId(remote.id)} type="button">{remote.label}</button>)}</nav></aside>
<main className="content">{selectedRemote && <section className="message"><h2>{selectedRemote.label}</h2><p>The remote loader is not connected.</p></section>}</main>
</div>
</div>
);
}
What Does This Component Own?
- The remotes array stores Catalog inside the shell for now.
- The selectedId state tracks the active navigation item.
- The header and sidebar remain outside the selected content panel.
- The placeholder makes the missing remote loader visible.
- Save App.tsx.
- Confirm that the Visual Studio Code Explorer lists App.tsx inside apps/shell/src.
Seeing Red Underlines in App.tsx?
Check that each opening brace has a matching closing brace. Confirm that the file ends with the component's final closing brace.
If the underline remains, help me compare my App.tsx structure with the shell component.
Connect the shell entry point
The entry point mounts your top-level component into the page's root element. It also loads the stylesheet that controls the shell's visual frame.
- Switch back to index.tsx from the previous step.
- Replace the temporary starter with this entry point by pasting the code below:
import { createRoot } from 'react-dom/client';
import App from './App';
import './styles.css';
const container = document.getElementById('root');
if (!container) {
throw new Error('Root element not found');
}
createRoot(container).render(<App />);
How Does the Shell Start?
- The createRoot import creates the React root used by the browser.
- The element check stops startup with a clear message if the page has no root mount point.
- The final line renders App as the shell's top-level component.
- The stylesheet import connects styles.css to the page.
- Save index.tsx.
- Return to the browser tab at http://localhost:2000.
- Refresh the page.
You should see an unstyled Root Shell header with an Applications area and a Catalog placeholder. That is the first visible win: your starter now renders the real shell component.
Seeing a Blank Page?
Check that App.tsx uses a default export. Confirm that index.tsx imports App from ./App.
If the browser reports Root element not found, help me diagnose the missing React root element.
Style and verify the shell
The shell needs a clear visual boundary around remote content. The CSS below gives the shell its persistent dark frame while leaving the main panel ready for application content.
- Create styles.css inside apps/shell/src by selecting New File in the Visual Studio Code Explorer toolbar.
- Add the page-wide foundation by pasting this first style group:
:root {
color: #e7eefb;
background: #08101f;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
button {
font: inherit;
}
.shell {
min-height: 100vh;
background: linear-gradient(145deg, #08101f 0%, #111d35 100%);
}
What Do These Foundation Styles Do?
- The :root selector establishes the shell's colors and font stack.
- The universal selector keeps element dimensions predictable.
- The body rules remove the browser's default margin.
- The shell gradient fills the complete viewport.
- Save styles.css.
- Refresh the shell page.
You should see the page switch to a dark blue gradient with light text.
Still Seeing a White Background?
Confirm that index.tsx imports ./styles.css. Check that styles.css sits beside index.tsx.
If the stylesheet still does not load, help me trace my shell stylesheet import.
- Append the persistent header styles to styles.css by pasting this code below the existing rules:
.shell-header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 96px;
padding: 20px 32px;
border-bottom: 1px solid #263550;
background: rgb(8 16 31 / 88%);
}
.shell-header h1,
.sidebar h2 {
margin: 0;
}
.eyebrow {
color: #74a7ff;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
Why Is the Header Separate?
- Flexbox places the shell identity and status badge at opposite sides of the header.
- The bottom border separates persistent shell controls from application content.
- The eyebrow styling gives the platform label a distinct visual role.
- Save styles.css.
- Refresh the shell page.
You should see a horizontal header with a blue Microfrontend Platform label above Root Shell.
Header Still Stacked Vertically?
Check that the selector starts with .shell-header. Confirm that the header in App.tsx uses the matching shell-header class.
If the classes match, help me debug the shell header layout.
- Append the status badge styles to styles.css by pasting this code below the header rules:
.status {
padding: 7px 12px;
border: 1px solid #3d5275;
border-radius: 999px;
color: #b9c9e5;
text-transform: capitalize;
}
.status-ready {
border-color: #2c7a5a;
color: #8ee2b8;
}
.status-error {
border-color: #9b3f4d;
color: #ff9daa;
}
What Do the Status Styles Prepare?
- The base .status rule turns the current loading text into a rounded badge.
- The ready style provides a green success state.
- The error style provides a red failure state.
- Save styles.css.
- Refresh the shell page.
You should see loading inside a rounded badge on the right side of the header.
Loading Text Has No Badge?
Confirm that the status element in App.tsx includes the status class. Check that the base selector includes the leading full stop.
If the badge remains unstyled, help me debug the status class.
- Append the shell body and sidebar styles to styles.css by pasting this code below the status rules:
.shell-body {
display: grid;
grid-template-columns: 230px 1fr;
min-height: calc(100vh - 96px);
}
.sidebar {
padding: 28px 20px;
border-right: 1px solid #263550;
}
.sidebar h2 {
margin-bottom: 16px;
color: #9fb1cf;
font-size: 0.9rem;
text-transform: uppercase;
}
.sidebar nav {
display: grid;
gap: 10px;
}
How Does the Shell Stay Persistent?
- The shell body reserves a fixed-width column for navigation.
- The remaining column expands to hold application content.
- The sidebar border keeps shell navigation visually separate from the selected application.
- Save styles.css.
- Refresh the shell page.
You should see a narrow Applications sidebar beside a larger content area.
Sidebar Taking the Full Width?
Check that .shell-body uses display: grid. Confirm that the matching wrapper in App.tsx uses the shell-body class.
If the grid still does not appear, help me fix the shell body grid.
- Append the navigation button styles to styles.css by pasting this code below the sidebar rules:
.nav-item {
width: 100%;
padding: 12px 14px;
border: 1px solid #2a3b5a;
border-radius: 10px;
color: #cad7eb;
background: #111d35;
cursor: pointer;
text-align: left;
}
.nav-item:hover,
.nav-item.active {
border-color: #5f97f5;
background: #17315c;
}
How Does Navigation Show Selection?
- Each navigation item fills the sidebar width.
- The active class gives the selected application a brighter border and background.
- The hover state previews the same visual emphasis before selection.
- Save styles.css.
- Refresh the shell page.
You should see Catalog as a blue active navigation button.
Catalog Button Not Highlighted?
Confirm that selectedId starts with catalog. Check that the button condition adds nav-item active when its ID matches.
If the active state remains missing, help me trace the Catalog selection state.
- Finish the content panel and responsive layout by appending this final style group:
.content {
padding: 32px;
}
.message {
max-width: 720px;
padding: 28px;
border: 1px dashed #3a5075;
border-radius: 16px;
background: rgb(15 28 51 / 80%);
}
.error-message {
border-color: #9b3f4d;
}
@media (max-width: 700px) {
.shell-body {
grid-template-columns: 1fr;
}
.sidebar {
border-right: 0;
border-bottom: 1px solid #263550;
}
}
What Does the Final Style Group Add?
- The content area gives every selected application consistent spacing.
- The message panel frames loading and placeholder content.
- The error class prepares a visible failure border.
- The media query stacks navigation above content on narrow screens.
- Save styles.css.
- Refresh the shell page.
- Narrow the browser window below 700px.
You should see the Catalog placeholder inside a dashed panel. At the narrow width, you should see the sidebar move above the content.
Content Panel Missing Its Border?
Confirm that the placeholder section uses the message class. Check that the .message selector appears outside the media query.
If the panel remains unstyled, help me debug the placeholder panel styles.
✔️ Awesome, I've got everything!
Great work. Save all three shell files before completing the final check.
ⓧ I'd like to double check the full code
Compare your current shell files with these complete versions. The Catalog definition remains intentionally hard-coded at this point.
import { useState } from 'react';
// Keep discovery temporary so its maintenance cost is visible.
const remotes = [
{
id: 'catalog',
label: 'Catalog',
remoteName: 'catalog',
entry: 'http://localhost:3001/mf-manifest.json',
exposedModule: 'App',
},
];
export default function App() {
// Track which hard-coded application owns the content panel.
const [selectedId, setSelectedId] = useState('catalog');
const selectedRemote = remotes.find((remote) => remote.id === selectedId);
return (
<div className="shell">
<header className="shell-header"><div><span className="eyebrow">Microfrontend Platform</span><h1>Root Shell</h1></div><span className="status status-loading">loading</span></header>
<div className="shell-body">
<aside className="sidebar"><h2>Applications</h2><nav aria-label="Microfrontends">{remotes.map((remote) => <button className={remote.id === selectedId ? 'nav-item active' : 'nav-item'} key={remote.id} onClick={() => setSelectedId(remote.id)} type="button">{remote.label}</button>)}</nav></aside>
<main className="content">{selectedRemote && <section className="message"><h2>{selectedRemote.label}</h2><p>The remote loader is not connected.</p></section>}</main>
</div>
</div>
);
}
import { createRoot } from 'react-dom/client';
import App from './App';
import './styles.css';
const container = document.getElementById('root');
if (!container) {
throw new Error('Root element not found');
}
createRoot(container).render(<App />);
:root {
color: #e7eefb;
background: #08101f;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
button {
font: inherit;
}
.shell {
min-height: 100vh;
background: linear-gradient(145deg, #08101f 0%, #111d35 100%);
}
.shell-header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 96px;
padding: 20px 32px;
border-bottom: 1px solid #263550;
background: rgb(8 16 31 / 88%);
}
.shell-header h1,
.sidebar h2 {
margin: 0;
}
.eyebrow {
color: #74a7ff;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.status {
padding: 7px 12px;
border: 1px solid #3d5275;
border-radius: 999px;
color: #b9c9e5;
text-transform: capitalize;
}
.status-ready {
border-color: #2c7a5a;
color: #8ee2b8;
}
.status-error {
border-color: #9b3f4d;
color: #ff9daa;
}
.shell-body {
display: grid;
grid-template-columns: 230px 1fr;
min-height: calc(100vh - 96px);
}
.sidebar {
padding: 28px 20px;
border-right: 1px solid #263550;
}
.sidebar h2 {
margin-bottom: 16px;
color: #9fb1cf;
font-size: 0.9rem;
text-transform: uppercase;
}
.sidebar nav {
display: grid;
gap: 10px;
}
.nav-item {
width: 100%;
padding: 12px 14px;
border: 1px solid #2a3b5a;
border-radius: 10px;
color: #cad7eb;
background: #111d35;
cursor: pointer;
text-align: left;
}
.nav-item:hover,
.nav-item.active {
border-color: #5f97f5;
background: #17315c;
}
.content {
padding: 32px;
}
.message {
max-width: 720px;
padding: 28px;
border: 1px dashed #3a5075;
border-radius: 16px;
background: rgb(15 28 51 / 80%);
}
.error-message {
border-color: #9b3f4d;
}
@media (max-width: 700px) {
.shell-body {
grid-template-columns: 1fr;
}
.sidebar {
border-right: 0;
border-bottom: 1px solid #263550;
}
}
Before the final check, which parts of the interface do you expect to remain visible when Catalog is selected?
- Return to the shell browser tab at http://localhost:2000.
- Refresh the page.
- Select the Catalog navigation button.
You should see the Root Shell header, loading badge, Applications sidebar, active Catalog button, and placeholder content together. The header and navigation remain visible around the selected content panel.
The page works, but its only application record still lives in App.tsx. Adding another application currently requires editing the remotes array and rebuilding the shell.
Your persistent shell now makes the static discovery problem visible. Next, an Express registry takes ownership of the application definitions.
Connect the Registry API
Your React shell now keeps its header and navigation visible. However, its hard-coded Catalog record makes every new application a shell source-code change.
This step moves the application list into an Express API. Each record carries the future Module Federation address that the shell needs for discovery.
In this step, get ready to:
- Create a typed registry that serves the Catalog definition.
- Fetch the registry data from the shell.
- Verify the shell's success and API failure states.
Build the typed registry server
The registry becomes the source of truth for available applications. Its TypeScript contract gives every record the same discovery fields.
The browser also needs permission to request the registry from a different local port. A CORS policy grants that permission to the shell origin.
- In the file tree sidebar from earlier, expand apps, then registry, then src.
- Click the file-addition icon beside src.
- Type server.ts into the filename field.
- Press Enter.
- Define the registry contract and Catalog record by pasting this code into server.ts:
import cors from 'cors';
import express from 'express';
interface RemoteDefinition {
id: string;
label: string;
remoteName: string;
entry: string;
exposedModule: string;
}
const remotes: RemoteDefinition[] = [
{
id: 'catalog',
label: 'Catalog',
remoteName: 'catalog',
entry: 'http://localhost:3001/mf-manifest.json',
exposedModule: 'App',
},
];
What does this registry record define?
- The RemoteDefinition interface requires every application to provide the same five discovery fields.
- The id and label fields give the shell a stable selection value and visible navigation text.
- The remoteName, entry, and exposedModule fields describe where a future remote module can be found.
- Save server.ts.
- Confirm that the editor shows no red TypeScript diagnostics in server.ts.
Seeing type errors in the registry record?
- Check that every property inside the Catalog record matches a property in RemoteDefinition.
- Check that the Catalog record ends with exposedModule: 'App'.
- Ask for help with the registry types.
The record describes what the shell can discover. The server now needs to publish that record through a route that the browser can reach.
- Place your cursor below the remotes array in server.ts.
- Add the server configuration and route by pasting this code:
const app = express();
app.use(
cors({
origin: 'http://localhost:2000',
}),
);
app.get('/api/remotes', (_request, response) => {
response.json({ remotes });
});
app.listen(3000, () => {
console.log('Registry API running at http://localhost:3000');
});
What does this server code do?
- The CORS middleware allows browser requests from the shell at http://localhost:2000.
- The GET /api/remotes route returns the typed array inside a remotes property.
- The listener keeps the registry available on local port 3000.
- Save server.ts.
- Create another PowerShell terminal by clicking the plus button in the terminal panel.
- Start the registry from the microfrontend-root-shell folder by running:
npm run dev:registry
You should see Registry API running at http://localhost:3000. Keep this terminal running because it now serves the registry.
- Return to your browser.
- Create a new browser tab.
- Click the browser address field.
- Enter http://localhost:3000/api/remotes.
- Press Enter.
You should see a JSON response containing the Catalog record. Good, your registry now publishes the complete discovery contract for the shell.
Registry route not loading?
- Check that the registry terminal is still running inside the microfrontend-root-shell folder.
- Check whether another process already occupies port 3000.
- Ask for help with the registry server.
✔️ Awesome, I've got everything!
Your registry is running. Keep its PowerShell terminal active while you connect the shell.
ⓧ I'd like to double check the full code
Compare your registry with the complete apps/registry/src/server.ts file below.
import cors from 'cors';
import express from 'express';
interface RemoteDefinition {
id: string;
label: string;
remoteName: string;
entry: string;
exposedModule: string;
}
const remotes: RemoteDefinition[] = [
{
id: 'catalog',
label: 'Catalog',
remoteName: 'catalog',
entry: 'http://localhost:3001/mf-manifest.json',
exposedModule: 'App',
},
];
const app = express();
app.use(
cors({
origin: 'http://localhost:2000',
}),
);
app.get('/api/remotes', (_request, response) => {
response.json({ remotes });
});
app.listen(3000, () => {
console.log('Registry API running at http://localhost:3000');
});
Fetch registry data in the shell
The shell needs local state for the registry request and its possible outcomes. A loading state covers the request window. An error state protects the shell when the backend is unavailable.
The next three snippets replace separate regions of App.tsx. Apply all three regions before saving the file.
- Switch back to apps/shell/src/App.tsx.
- Select the imports and temporary hard-coded Catalog definition above export default function App().
- Replace that selected region with the registry types and URL below:
import { useEffect, useState } from 'react';
interface RemoteDefinition {
id: string;
label: string;
remoteName: string;
entry: string;
exposedModule: string;
}
interface RegistryResponse {
remotes: RemoteDefinition[];
}
const registryUrl = 'http://localhost:3000/api/remotes';
What does this data contract do?
- The shell's RemoteDefinition matches the record shape returned by the registry.
- The RegistryResponse interface describes the object wrapped around the remote array.
- The registryUrl constant keeps the backend address in one place.
- Inside App(), select everything before its existing return ( statement.
- Replace that selected region with the request state and effect below:
const [remotes, setRemotes] = useState<RemoteDefinition[]>([]);
const [selectedId, setSelectedId] = useState('');
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
const [errorMessage, setErrorMessage] = useState('');
useEffect(() => {
let cancelled = false;
async function loadRegistry() {
try {
const response = await fetch(registryUrl);
if (!response.ok) {
throw new Error(`Registry request failed with status ${response.status}.`);
}
const data = (await response.json()) as RegistryResponse;
if (!cancelled) {
setRemotes(data.remotes);
setSelectedId(data.remotes[0]?.id ?? '');
setStatus('ready');
}
} catch (error) {
if (!cancelled) {
setErrorMessage(error instanceof Error ? error.message : 'Unknown registry error.');
setStatus('error');
}
}
}
void loadRegistry();
return () => { cancelled = true; };
}, []);
const selectedRemote = remotes.find((remote) => remote.id === selectedId);
How does the registry request work?
- The shell starts with an empty remote list and a loading status.
- The effect fetches the registry once after the component mounts.
- A successful response stores the remotes and selects the first record.
- A failed response stores its message and changes the status to error.
- Select the existing return ( statement and its complete JSX block.
- Replace that selected region with the registry-driven shell interface below:
return (
<div className="shell">
<header className="shell-header">
<div>
<span className="eyebrow">Microfrontend Platform</span>
<h1>Root Shell</h1>
</div>
<span className={`status status-${status}`}>{status}</span>
</header>
<div className="shell-body">
<aside className="sidebar">
<h2>Applications</h2>
<nav aria-label="Microfrontends">
{remotes.map((remote) => (
<button className={remote.id === selectedId ? 'nav-item active' : 'nav-item'} key={remote.id} onClick={() => setSelectedId(remote.id)} type="button">
{remote.label}
</button>
))}
</nav>
</aside>
<main className="content">
{status === 'loading' && (<section className="message"><h2>Loading registry</h2><p>Discovering available microfrontends.</p></section>)}
{status === 'error' && (<section className="message error-message"><h2>Registry unavailable</h2><p>{errorMessage}</p></section>)}
{status === 'ready' && remotes.length === 0 && (<section className="message"><h2>No applications registered</h2><p>Add a remote definition to the registry API.</p></section>)}
{status === 'ready' && selectedRemote && (<section className="message"><h2>{selectedRemote.label}</h2><p>The remote loader is not connected.</p></section>)}
</main>
</div>
</div>
);
How does the shell respond?
- The status badge reflects the current request state.
- The navigation buttons now come from the API-provided remotes array.
- The content area handles loading, failure, and empty registry results.
- A selected record still renders placeholder content because runtime loading comes later.
- Save App.tsx.
- Return to the shell tab at http://localhost:2000.
- Refresh the page.
You should see the status change to ready. The Catalog button now comes from the registry response.
Select Catalog to see the placeholder panel. That result proves the backend controls discovery while the shell still owns the frame.
Shell stuck on an error state?
- Confirm that the registry terminal still shows a running process.
- Check that registryUrl uses http://localhost:3000/api/remotes.
- Check that the registry allows the shell origin http://localhost:2000.
- Ask for help with the shell request.
✔️ Awesome, I've got everything!
Your shell now discovers Catalog through the registry. Make sure App.tsx is saved before testing the failure state.
ⓧ I'd like to double check the full code
Compare your shell component with the complete apps/shell/src/App.tsx file for this step.
import { useEffect, useState } from 'react';
interface RemoteDefinition {
id: string;
label: string;
remoteName: string;
entry: string;
exposedModule: string;
}
interface RegistryResponse {
remotes: RemoteDefinition[];
}
const registryUrl = 'http://localhost:3000/api/remotes';
export default function App() {
const [remotes, setRemotes] = useState<RemoteDefinition[]>([]);
const [selectedId, setSelectedId] = useState('');
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
const [errorMessage, setErrorMessage] = useState('');
useEffect(() => {
let cancelled = false;
async function loadRegistry() {
try {
const response = await fetch(registryUrl);
if (!response.ok) {
throw new Error(`Registry request failed with status ${response.status}.`);
}
const data = (await response.json()) as RegistryResponse;
if (!cancelled) {
setRemotes(data.remotes);
setSelectedId(data.remotes[0]?.id ?? '');
setStatus('ready');
}
} catch (error) {
if (!cancelled) {
setErrorMessage(error instanceof Error ? error.message : 'Unknown registry error.');
setStatus('error');
}
}
}
void loadRegistry();
return () => { cancelled = true; };
}, []);
const selectedRemote = remotes.find((remote) => remote.id === selectedId);
return (
<div className="shell">
<header className="shell-header">
<div>
<span className="eyebrow">Microfrontend Platform</span>
<h1>Root Shell</h1>
</div>
<span className={`status status-${status}`}>{status}</span>
</header>
<div className="shell-body">
<aside className="sidebar">
<h2>Applications</h2>
<nav aria-label="Microfrontends">
{remotes.map((remote) => (
<button className={remote.id === selectedId ? 'nav-item active' : 'nav-item'} key={remote.id} onClick={() => setSelectedId(remote.id)} type="button">
{remote.label}
</button>
))}
</nav>
</aside>
<main className="content">
{status === 'loading' && (<section className="message"><h2>Loading registry</h2><p>Discovering available microfrontends.</p></section>)}
{status === 'error' && (<section className="message error-message"><h2>Registry unavailable</h2><p>{errorMessage}</p></section>)}
{status === 'ready' && remotes.length === 0 && (<section className="message"><h2>No applications registered</h2><p>Add a remote definition to the registry API.</p></section>)}
{status === 'ready' && selectedRemote && (<section className="message"><h2>{selectedRemote.label}</h2><p>The remote loader is not connected.</p></section>)}
</main>
</div>
</div>
);
}
Test success and failure states
The shell should keep its layout even when the registry disappears. This check proves the failure state protects the frame.
Before you refresh, decide whether you expect the Root Shell header to survive when the registry is offline.
- Switch back to the registry terminal.
- Press Ctrl+C to stop the registry process.
- Return to the shell browser tab.
- Refresh the page.
You should see Registry unavailable in the content area. The Root Shell header and Applications sidebar remain visible.
Before you restart the API, predict whether Catalog can return without another shell code change.
- Return to the registry terminal.
- Restart the registry by running:
npm run dev:registry
- Return to the shell browser tab.
- Refresh the page.
- Select Catalog.
You should see the ready status and the Catalog placeholder. The Root Shell header remains visible throughout the recovery.
Catalog not returning after the restart?
- Wait until the registry terminal confirms that the API is running before refreshing the shell.
- Check that the browser is using the shell address http://localhost:2000.
- Ask for help with the recovery check.
Your backend now controls which applications appear in the shell. Next, you will publish the standalone Catalog producer that fulfills this registry promise.
Publish the Catalog Microfrontend
Your backend service registry now tells the shell that Catalog exists. That record still points to a promise because no producer serves the registered manifest.
This step turns that promise into an independently served React microfrontend. Rsbuild gives the Catalog app its own development server.
Module Federation publishes the Catalog module through a remote manifest. The Catalog server also permits the shell origin through CORS.
In this step, get ready to:
- Build the Catalog product interface.
- Run Catalog as an independent app on its own port.
- Publish the Catalog module through a Module Federation manifest.
Build the Catalog product interface
The Catalog owns its product data and page markup. This separation lets it run without relying on the shell.
- In the Visual Studio Code Explorer sidebar, expand apps/catalog/src.
- Create an empty styles.css file inside apps/catalog/src using the file icon with a plus sign.
You should see styles.css listed inside the Catalog source folder.
- Create CatalogApp.tsx inside apps/catalog/src using the same file control.
- Build the product interface by pasting this code into CatalogApp.tsx:
const products = [
{ id: 1, name: 'Aurora Keyboard', price: '$129' },
{ id: 2, name: 'Orbit Mouse', price: '$79' },
{ id: 3, name: 'Nova Display', price: '$349' },
];
export default function CatalogApp() {
return (
<section className="catalog-app">
<span className="catalog-kicker">Remote application</span>
<h2>Product Catalog</h2>
<p>This interface is rendered by the independently served Catalog app.</p>
<div className="product-grid">
{products.map((product) => (
<article className="product-card" key={product.id}>
<span>SKU-{product.id.toString().padStart(3, '0')}</span>
<h3>{product.name}</h3>
<strong>{product.price}</strong>
</article>
))}
</div>
</section>
);
}
What Does This Component Do?
- The products array keeps the product names and prices inside the Catalog app.
- CatalogApp is the default export that Module Federation exposes later.
- products.map() creates one card for each product.
- padStart() turns each numeric ID into a three-digit SKU label.
- Save CatalogApp.tsx.
- Confirm that CatalogApp.tsx appears beside styles.css in the Explorer sidebar.
The Catalog now owns a complete product interface with three product cards.
Seeing TypeScript Errors in CatalogApp?
- Check that every product object contains id, name, and price properties.
- Check that the closing tags match the opening section, div, and article tags.
- help me compare my CatalogApp component with the expected TypeScript structure
Configure and run the Catalog producer
A standalone React page needs an entry point that mounts the Catalog component. The producer configuration gives that page port 3001.
- Create index.tsx inside apps/catalog/src using the file control in the Explorer sidebar.
- Add the Catalog entry point by pasting this code into index.tsx:
import { createRoot } from 'react-dom/client';
import CatalogApp from './CatalogApp';
import './styles.css';
const container = document.getElementById('root');
if (!container) {
throw new Error('Root element not found');
}
createRoot(container).render(<CatalogApp />);
How Does the Entry Point Work?
- createRoot connects React DOM to the page's root element.
- The container check stops startup with a clear error when the root element is unavailable.
- The final line renders CatalogApp as the standalone page.
- Save index.tsx.
- Check Visual Studio Code's problem list for index.tsx.
You should see no unresolved imports because CatalogApp.tsx and styles.css now exist beside the entry point.
Seeing an Import Error?
- Confirm that CatalogApp.tsx uses the same capitalization as the ./CatalogApp import.
- Confirm that styles.css sits inside apps/catalog/src.
- help me resolve the Catalog entry point imports
The Catalog also needs a producer contract. Its configuration names the producer and maps the public ./App module to ./src/CatalogApp.
- Create rsbuild.config.ts inside apps/catalog using the file control in the Explorer sidebar.
- Configure the Catalog producer by pasting this code into rsbuild.config.ts:
import { pluginModuleFederation } from '@module-federation/rsbuild-plugin';
import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';
export default defineConfig({
server: {
port: 3001,
cors: {
origin: 'http://localhost:2000',
},
},
plugins: [
pluginReact(),
pluginModuleFederation({
name: 'catalog',
manifest: true,
exposes: {
'./App': './src/CatalogApp',
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
},
}),
],
});
What Does This Configuration Do?
- server.port keeps Catalog on http://localhost:3001.
- server.cors permits requests from the shell at http://localhost:2000.
- manifest: true publishes the producer metadata in mf-manifest.json.
- singleton: true keeps the shell and remote on one shared React instance.
- Save rsbuild.config.ts.
- Use the plus button in Visual Studio Code's terminal panel to create a third PowerShell terminal.
Before you start Catalog, what product details do you expect its standalone page to show?
- Start the independent Catalog development server by running:
npm run dev:catalog
What Does This Command Start?
The root script moves into apps/catalog and starts the Rsbuild development server. Leave this terminal running because it serves both the standalone page and the remote manifest.
- Open http://localhost:3001 in your browser.
You should see the Product Catalog heading with Aurora Keyboard, Orbit Mouse, and Nova Display beneath it. The page is unstyled because its stylesheet is still empty.
That is the first major proof: Catalog runs independently from the shell and registry.
Catalog Page Not Loading?
- Check that the Catalog terminal remains active after the development server starts.
- Check that rsbuild.config.ts is inside apps/catalog.
- Confirm that no other local process is already using port 3001.
- help me troubleshoot the Catalog development server
Style and verify the published microfrontend
The standalone page proves the component works. You can now style it through small browser feedback loops while the development server stays active.
- Return to the empty apps/catalog/src/styles.css file.
- Create the main Catalog panel by pasting this code:
.catalog-app {
color: #17233a;
padding: 30px;
border-radius: 18px;
background: #f4f7fc;
box-shadow: 0 18px 45px rgb(0 0 0 / 20%);
}
.catalog-app h2 {
margin: 6px 0;
font-size: 2rem;
}
What Do These Styles Change?
- The .catalog-app selector turns the remote content into a light panel inside the dark shell.
- The heading rule increases the visual weight of Product Catalog.
- Save styles.css.
- Return to the Catalog page in your browser.
You should see the Catalog content inside a rounded light panel with a shadow.
Panel Still Unstyled?
- Confirm that index.tsx imports ./styles.css.
- Confirm that the selector begins with .catalog-app.
- help me find why the Catalog panel styles are not applying
- Add the Catalog kicker styles below the existing heading rule:
.catalog-kicker {
color: #245ba9;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
What Does the Kicker Communicate?
The smaller uppercase label identifies this content as a remote application. Its blue color separates the producer label from the page heading.
- Save styles.css.
- Return to the Catalog page in your browser.
You should see Remote application as a small blue uppercase label above Product Catalog.
Kicker Still Looks Like Plain Text?
- Check that CatalogApp.tsx uses className="catalog-kicker".
- Check that the CSS selector includes the leading period.
- help me troubleshoot the Catalog kicker styles
- Add the responsive product grid below the kicker rule:
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 16px;
margin-top: 24px;
}
How Does the Grid Respond?
The grid creates as many columns as the available width can support. Each column keeps a minimum width of 170px before wrapping.
- Save styles.css.
- Return to the Catalog page in your browser.
You should see the three products arranged in responsive columns with even spacing.
Products Still Stacked Without Spacing?
- Confirm that the product wrapper uses className="product-grid".
- Confirm that the CSS rule contains display: grid.
- help me troubleshoot the Catalog product grid
- Finish the product cards by adding these rules below the grid:
.product-card {
padding: 18px;
border: 1px solid #d7e1f0;
border-radius: 12px;
background: white;
}
.product-card span {
color: #63718a;
font-size: 0.8rem;
}
.product-card h3 {
margin: 14px 0;
}
What Finishes Each Card?
- The card rule adds spacing and a white surface around each product.
- The nested span rule gives each SKU a quieter text style.
- The heading margin separates each product name from its SKU and price.
- Save styles.css.
- Return to the Catalog page in your browser.
You should see three white product cards inside the light Catalog panel. Each card shows a muted SKU, a product name, and a price.
Cards Missing Their White Surfaces?
- Confirm that each product article uses className="product-card".
- Confirm that the .product-card rule appears after the grid rule.
- help me troubleshoot the Catalog card styles
The styled Catalog now works as its own application. The last check proves that it also publishes the producer contract promised by the registry.
Before you check the manifest, what information do you expect a shell to need before it can request the exposed Catalog module?
- Open http://localhost:3001/mf-manifest.json in a new browser tab.
You should see a JSON manifest describing the catalog producer and its exposed module. This proves the registered manifest address now points to a live producer.
Strong work. Catalog now runs independently and publishes the metadata needed for runtime loading.
Manifest Not Available?
- Confirm that the Catalog development server is still running.
- Confirm that manifest: true remains inside the pluginModuleFederation configuration.
- Confirm that the address ends with mf-manifest.json.
- help me troubleshoot the missing Module Federation manifest
✔️ Awesome, I've got everything!
Your Catalog app is running independently at http://localhost:3001. Its Module Federation manifest is also available.
- Save every file under apps/catalog.
- Leave the Catalog development server running for the next step.
ⓧ I'd like to double check the full code
Compare your producer configuration with this complete apps/catalog/rsbuild.config.ts file.
import { pluginModuleFederation } from '@module-federation/rsbuild-plugin';
import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';
export default defineConfig({
server: {
port: 3001,
cors: {
origin: 'http://localhost:2000',
},
},
plugins: [
pluginReact(),
pluginModuleFederation({
name: 'catalog',
manifest: true,
exposes: {
'./App': './src/CatalogApp',
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
},
}),
],
});
Producer Configuration Check
This reference includes the Catalog port, shell origin, exposed module, manifest setting, and shared singleton dependencies.
Compare your component with this complete apps/catalog/src/CatalogApp.tsx file.
const products = [
{ id: 1, name: 'Aurora Keyboard', price: '$129' },
{ id: 2, name: 'Orbit Mouse', price: '$79' },
{ id: 3, name: 'Nova Display', price: '$349' },
];
export default function CatalogApp() {
return (
<section className="catalog-app">
<span className="catalog-kicker">Remote application</span>
<h2>Product Catalog</h2>
<p>This interface is rendered by the independently served Catalog app.</p>
<div className="product-grid">
{products.map((product) => (
<article className="product-card" key={product.id}>
<span>SKU-{product.id.toString().padStart(3, '0')}</span>
<h3>{product.name}</h3>
<strong>{product.price}</strong>
</article>
))}
</div>
</section>
);
}
Catalog Component Check
This reference contains the three product records and the default Catalog component exposed by the producer.
Compare your entry point with this complete apps/catalog/src/index.tsx file.
import { createRoot } from 'react-dom/client';
import CatalogApp from './CatalogApp';
import './styles.css';
const container = document.getElementById('root');
if (!container) {
throw new Error('Root element not found');
}
createRoot(container).render(<CatalogApp />);
Catalog Entry Point Check
This reference imports the component and stylesheet before rendering Catalog into the root element.
Compare your stylesheet with this complete apps/catalog/src/styles.css file.
.catalog-app {
color: #17233a;
padding: 30px;
border-radius: 18px;
background: #f4f7fc;
box-shadow: 0 18px 45px rgb(0 0 0 / 20%);
}
.catalog-app h2 {
margin: 6px 0;
font-size: 2rem;
}
.catalog-kicker {
color: #245ba9;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 16px;
margin-top: 24px;
}
.product-card {
padding: 18px;
border: 1px solid #d7e1f0;
border-radius: 12px;
background: white;
}
.product-card span {
color: #63718a;
font-size: 0.8rem;
}
.product-card h3 {
margin: 14px 0;
}
Catalog Stylesheet Check
This reference styles the Catalog panel, producer label, responsive grid, and product cards.
The registry now points to a real Catalog producer with a live manifest. Next, you will connect that metadata to the shell's runtime loader so Catalog can render inside the persistent shell.
Register and Load the Remote
Your registry can now tell the shell that Catalog exists. The shell still stops at a placeholder because it has no runtime path from that metadata to the rendered application.
In this step, you will use Module Federation to register the manifest after the API responds. You will turn each record into a lazy React component that loads inside the persistent shell.
In this step, get ready to:
- Configure the shell as a Module Federation host with no static remotes.
- Turn registry records into lazy React components.
- Protect remote rendering with loading and failure interfaces.
Configure the shell runtime
The shell needs a default Module Federation runtime before it can register remotes dynamically. The Rsbuild plugin creates that runtime while an empty remotes object keeps remote addresses under API control.
- Select apps/shell/rsbuild.config.ts in the Visual Studio Code Explorer sidebar.
- Replace the file contents with this shell configuration:
import { pluginModuleFederation } from '@module-federation/rsbuild-plugin';
import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';
export default defineConfig({
server: {
port: 2000,
},
plugins: [
pluginReact(),
pluginModuleFederation({
name: 'root_shell',
remotes: {},
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
},
}),
],
});
What does this configuration do?
- The root_shell name identifies this build as the Module Federation host.
- The empty remotes object leaves remote registration to the API-driven runtime logic.
- The singleton settings keep the shell and Catalog on one shared React instance.
Why runtime registration?
Build-time remotes would place the Catalog address inside the shell configuration. Runtime registration lets the registry API provide that address after the shell has already been built.
- Save apps/shell/rsbuild.config.ts.
- Switch back to the shell terminal from earlier to confirm its development process restarts successfully.
- Refresh http://localhost:2000 in your browser.
You should still see the Root Shell header with the API-generated Catalog navigation item. The content area still shows the placeholder while the runtime loader is disconnected.
Shell process fails to restart?
- Check that pluginModuleFederation appears inside the plugins array.
- Check that the imported package path is @module-federation/rsbuild-plugin.
- Ask for help with the shell runtime configuration.
Register and lazy-load the Catalog remote
A registry record contains the remote name and manifest entry. The shell must register each producer once before it can request an exposed module such as catalog/App.
- Select apps/shell/src/App.tsx in the Visual Studio Code Explorer sidebar.
- Replace the current import section with these runtime and React imports:
import { loadRemote, registerRemotes } from '@module-federation/enhanced/runtime';
import {
Component,
Suspense,
lazy,
useEffect,
useState,
type ComponentType,
type LazyExoticComponent,
type ReactNode,
} from 'react';
What do these imports provide?
- The registerRemotes function adds API-provided producers to the default runtime instance.
- The loadRemote function requests an exposed module by its remote identifier.
- The lazy function turns the asynchronous module request into a renderable component.
- The Suspense component provides visible content during that asynchronous request.
- Add these remote component interfaces directly below the existing RegistryResponse interface:
interface RemoteItem extends RemoteDefinition {
Component: LazyExoticComponent<ComponentType>;
}
interface RemoteModule {
default: ComponentType;
}
What do these types represent?
- The RemoteItem interface combines registry metadata with the lazy component created from that metadata.
- The RemoteModule interface describes the default component export expected from Catalog.
- Save apps/shell/src/App.tsx.
- Switch to the shell terminal to confirm the updated imports compile successfully.
The shell process should finish recompiling without a TypeScript error.
- Add createLazyRemote directly below the existing registryUrl declaration:
function createLazyRemote(remote: RemoteDefinition) {
return lazy(async () => {
const remoteId = `${remote.remoteName}/${remote.exposedModule}`;
const remoteModule = await loadRemote<RemoteModule>(remoteId);
if (!remoteModule?.default) {
throw new Error(`Remote module ${remoteId} did not provide a default export.`);
}
return { default: remoteModule.default };
});
}
How does the lazy loader work?
- The remoteId value combines the API fields into the identifier expected by the runtime.
- The loadRemote call retrieves the exposed component only when React tries to render it.
- The default-export check turns an invalid module response into an error the boundary can handle.
- Update the remotes state declaration inside App so it stores lazy remote items:
const [remotes, setRemotes] = useState<RemoteItem[]>([]);
Why change the state type?
Each stored item now carries its registry fields plus a lazy component. Navigation and rendering can use the same selected record.
- Find the const data = (await response.json()) as RegistryResponse; line inside loadRegistry.
- Replace the current success-state assignments below that line with this registration logic:
const uniqueRemotes = Array.from(
new Map(
data.remotes.map((remote) => [
remote.remoteName,
{ name: remote.remoteName, entry: remote.entry },
]),
).values(),
);
registerRemotes(uniqueRemotes);
const remoteItems = data.remotes.map((remote) => ({
...remote,
Component: createLazyRemote(remote),
}));
if (!cancelled) {
setRemotes(remoteItems);
setSelectedId(remoteItems[0]?.id ?? '');
setStatus('ready');
}
What happens after the API responds?
- The Map groups records by remoteName so one producer is registered once.
- The registerRemotes call gives the runtime the producer name and manifest entry.
- The remoteItems transformation creates one lazy component for each exposed page in the registry.
- The first registry item becomes the selected navigation item when the request succeeds.
- Save apps/shell/src/App.tsx.
- Refresh http://localhost:2000 in your browser.
You should see the status return to ready with Catalog selected in the sidebar. The placeholder remains until you connect the selected lazy component to the content area.
Registry status stays on error?
- Confirm the registry process from earlier is still running on port 3000.
- Check that registerRemotes receives uniqueRemotes before lazy components are stored.
- Ask for help with the runtime registration flow.
Render and protect remote content
Remote code arrives over the network after the shell has rendered. Suspense keeps that wait visible while an error boundary prevents a failed remote from replacing the entire shell with a blank page.
- Add these error-boundary interfaces directly below RemoteModule and above registryUrl.
interface ErrorBoundaryProps {
children: ReactNode;
remoteLabel: string;
}
interface ErrorBoundaryState {
hasError: boolean;
}
What state does the boundary need?
The props carry the remote label and protected child component. The hasError state decides whether to show remote content or the unavailable message.
- Add the RemoteErrorBoundary class below createLazyRemote and above App.
class RemoteErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
state: ErrorBoundaryState = { hasError: false };
static getDerivedStateFromError(): ErrorBoundaryState {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return (
<section className="message error-message">
<h2>{this.props.remoteLabel} is unavailable</h2>
<p>The shell is still running. Start the remote and refresh the page.</p>
</section>
);
}
return this.props.children;
}
}
How does the boundary protect the shell?
- The getDerivedStateFromError method records a failure thrown while the lazy component loads or renders.
- The fallback stays inside the shell content area because the header and sidebar sit outside the boundary.
- The remote label makes the failure message specific to the selected application.
- Save apps/shell/src/App.tsx.
- Switch to the shell terminal to confirm the boundary compiles successfully.
The shell process should complete its rebuild without a TypeScript error.
- Find the ready-state placeholder for selectedRemote inside the main element.
- Replace that placeholder with the protected remote renderer:
{status === 'ready' && selectedRemote && (
<RemoteErrorBoundary
key={selectedRemote.id}
remoteLabel={selectedRemote.label}
>
<Suspense
fallback={
<section className="message">
<h2>Loading {selectedRemote.label}</h2>
<p>Fetching its federated module.</p>
</section>
}
>
<selectedRemote.Component />
</Suspense>
</RemoteErrorBoundary>
)}
What does the renderer provide?
- The boundary receives a key based on the selected record so switching pages creates a fresh failure state.
- The Suspense fallback fills the content area while the federated module downloads.
- The selectedRemote.Component expression renders the lazy component created from the registry record.
- Save apps/shell/src/App.tsx.
Before you refresh, which parts of the page do you expect to remain visible while Catalog loads?
- Refresh http://localhost:2000 in your browser.
- Select the Catalog navigation button if it is not already active.
You should briefly see the Catalog loading panel. You should then see the Aurora Keyboard, Orbit Mouse, and Nova Display cards inside the persistent Root Shell frame.
That connection is live. Your API record now controls which independently served application the shell discovers and renders.
Before you test the safety net, do you think stopping Catalog will remove the whole page or only replace the remote content?
- Switch back to the Catalog terminal from earlier.
- Press Ctrl+C to stop the Catalog process.
- Refresh http://localhost:2000 in your browser.
You should see Catalog is unavailable in the content area. The Root Shell header and Catalog navigation remain visible, which proves the boundary contains the remote failure.
- Switch back to the stopped Catalog terminal.
- Press the Up Arrow key to recall its previous start command.
- Press Enter to restart the Catalog producer.
- Refresh http://localhost:2000 after the Catalog server is ready.
The Product Catalog should load again with all three product cards. The shell, registry API, and Catalog producer are now running in their three terminals.
Catalog remains unavailable?
- Confirm the Catalog terminal shows that its development server is running on port 3001.
- Confirm the registry terminal is still serving the Catalog manifest entry.
- Refresh the shell after the Catalog process is ready so the error boundary receives a fresh page state.
- Ask for help tracing the failed remote load.
✔️ Awesome, I've got everything!
- Save apps/shell/rsbuild.config.ts.
- Save apps/shell/src/App.tsx.
- Confirm the Catalog cards render inside the persistent shell.
ⓧ I'd like to double check the full code
Compare your shell configuration with this complete version.
import { pluginModuleFederation } from '@module-federation/rsbuild-plugin';
import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';
export default defineConfig({
server: {
port: 2000,
},
plugins: [
pluginReact(),
pluginModuleFederation({
name: 'root_shell',
remotes: {},
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
},
}),
],
});
This file creates the default host runtime while leaving all remote addresses to the registry-driven code.
Compare apps/shell/src/App.tsx with this complete runtime shell.
import { loadRemote, registerRemotes } from '@module-federation/enhanced/runtime';
import {
Component,
Suspense,
lazy,
useEffect,
useState,
type ComponentType,
type LazyExoticComponent,
type ReactNode,
} from 'react';
interface RemoteDefinition {
id: string;
label: string;
remoteName: string;
entry: string;
exposedModule: string;
}
interface RegistryResponse {
remotes: RemoteDefinition[];
}
interface RemoteItem extends RemoteDefinition {
Component: LazyExoticComponent<ComponentType>;
}
interface RemoteModule {
default: ComponentType;
}
interface ErrorBoundaryProps {
children: ReactNode;
remoteLabel: string;
}
interface ErrorBoundaryState {
hasError: boolean;
}
const registryUrl = 'http://localhost:3000/api/remotes';
function createLazyRemote(remote: RemoteDefinition) {
return lazy(async () => {
const remoteId = `${remote.remoteName}/${remote.exposedModule}`;
const remoteModule = await loadRemote<RemoteModule>(remoteId);
if (!remoteModule?.default) {
throw new Error(`Remote module ${remoteId} did not provide a default export.`);
}
return { default: remoteModule.default };
});
}
class RemoteErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
state: ErrorBoundaryState = { hasError: false };
static getDerivedStateFromError(): ErrorBoundaryState {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return (
<section className="message error-message">
<h2>{this.props.remoteLabel} is unavailable</h2>
<p>The shell is still running. Start the remote and refresh the page.</p>
</section>
);
}
return this.props.children;
}
}
export default function App() {
const [remotes, setRemotes] = useState<RemoteItem[]>([]);
const [selectedId, setSelectedId] = useState('');
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
const [errorMessage, setErrorMessage] = useState('');
useEffect(() => {
let cancelled = false;
async function loadRegistry() {
try {
const response = await fetch(registryUrl);
if (!response.ok) {
throw new Error(`Registry request failed with status ${response.status}.`);
}
const data = (await response.json()) as RegistryResponse;
const uniqueRemotes = Array.from(
new Map(
data.remotes.map((remote) => [
remote.remoteName,
{ name: remote.remoteName, entry: remote.entry },
]),
).values(),
);
registerRemotes(uniqueRemotes);
const remoteItems = data.remotes.map((remote) => ({
...remote,
Component: createLazyRemote(remote),
}));
if (!cancelled) {
setRemotes(remoteItems);
setSelectedId(remoteItems[0]?.id ?? '');
setStatus('ready');
}
} catch (error) {
if (!cancelled) {
setErrorMessage(
error instanceof Error ? error.message : 'Unknown registry error.',
);
setStatus('error');
}
}
}
void loadRegistry();
return () => {
cancelled = true;
};
}, []);
const selectedRemote = remotes.find((remote) => remote.id === selectedId);
return (
<div className="shell">
<header className="shell-header">
<div>
<span className="eyebrow">Microfrontend Platform</span>
<h1>Root Shell</h1>
</div>
<span className={`status status-${status}`}>{status}</span>
</header>
<div className="shell-body">
<aside className="sidebar">
<h2>Applications</h2>
<nav aria-label="Microfrontends">
{remotes.map((remote) => (
<button
className={remote.id === selectedId ? 'nav-item active' : 'nav-item'}
key={remote.id}
onClick={() => setSelectedId(remote.id)}
type="button"
>
{remote.label}
</button>
))}
</nav>
</aside>
<main className="content">
{status === 'loading' && (
<section className="message">
<h2>Loading registry</h2>
<p>Discovering available microfrontends.</p>
</section>
)}
{status === 'error' && (
<section className="message error-message">
<h2>Registry unavailable</h2>
<p>{errorMessage}</p>
</section>
)}
{status === 'ready' && remotes.length === 0 && (
<section className="message">
<h2>No applications registered</h2>
<p>Add a remote definition to the registry API.</p>
</section>
)}
{status === 'ready' && selectedRemote && (
<RemoteErrorBoundary
key={selectedRemote.id}
remoteLabel={selectedRemote.label}
>
<Suspense
fallback={
<section className="message">
<h2>Loading {selectedRemote.label}</h2>
<p>Fetching its federated module.</p>
</section>
}
>
<selectedRemote.Component />
</Suspense>
</RemoteErrorBoundary>
)}
</main>
</div>
</div>
);
}
This file converts the registry response into unique runtime registrations and lazy components. It keeps remote loading failures inside the shell content area.
Secret mission
Add a Second Registry-Driven Page
Publish a Deals page from the existing Catalog producer. A new registry record makes it appear in the shell without changing or rebuilding any shell code.
Clean Up Your Resources
Clean Up Your Resources
Everything in this project runs locally. There are no ongoing costs.
Keep the apps running if you plan to continue now. Otherwise, pause the processes to free memory or delete the workspace entirely.
Resources you used:
- The microfrontend-root-shell folder on your Desktop.
- The shell development process in Visual Studio Code at http://localhost:2000.
- The registry API process at http://localhost:3000.
- The Catalog producer process at http://localhost:3001.
Keep everything running
No action is needed. This option keeps the full three-process system available for more experiments.
- The shell remains available at http://localhost:2000.
- The registry API continues serving Catalog and Deals at http://localhost:3000.
- The Catalog producer continues serving both exposed pages at http://localhost:3001.
- The microfrontend-root-shell folder remains on your Desktop.
Pause - I'll come back to this later
Pausing stops the three development processes to free memory. The microfrontend-root-shell folder stays on your Desktop.
- Switch back to the shell terminal from earlier.
- Press Ctrl+C to stop the shell process.
- Switch back to the registry terminal from earlier.
- Press Ctrl+C to stop the registry process.
- Switch back to the Catalog terminal from earlier.
- Press Ctrl+C to stop the Catalog process.
- Refresh the shell browser tab from earlier.
The shell address no longer loads because its development process is stopped. Your project files are ready for another session.
Delete - I don't want to use this again
This option removes every local project file. It also stops the three development processes.
- Stop the shell process by pressing Ctrl+C in the shell terminal from earlier.
- Stop the registry process by pressing Ctrl+C in the registry terminal from earlier.
- Stop the Catalog process by pressing Ctrl+C in the Catalog terminal from earlier.
- Close the Visual Studio Code window.
All three development processes are now stopped. Your other local projects remain unaffected.
The next action permanently removes only the microfrontend-root-shell folder from your Desktop. It skips the Recycle Bin.
- Press Windows+E to open File Explorer.
- Select Desktop in the left sidebar.
- Select the microfrontend-root-shell folder.
- Press Shift+Delete.
- Approve the confirmation prompt.
- Confirm that the microfrontend-root-shell folder no longer appears on your Desktop.
That clears the complete three-app workspace from your computer. No local server or project file remains from this build.
Nice Work!
Nice Work!
Outstanding work! You brought a full-stack microfrontend system to life across three local processes.
You've learned how to:
- Built a persistent React root shell with API-driven navigation. Its loading interface shows progress. Its API failure state protects the layout. Its remote error boundary keeps the shell frame running.
- Created a typed Express registry that controls which remote pages appear. The shell discovers each page through a shared registry contract.
- Published the Catalog producer through Module Federation. The shell registers its manifest at runtime. Lazy loading renders the selected module inside the persistent frame.
- Secret Mission: Added a second Deals page to the producer. The unchanged shell discovers it through the registry. The same runtime loader renders it without a shell rebuild.
Ready to quiz yourself?