Front end, inline (React + Vite, Module Federation)
Inside Blu, the shell loads the module's federated entry into its own page and renders the exposed component. The same component also runs standalone through @blu/sdk/dev.
1. One exposed page component
Expose one component (by convention ./Page) that renders the module. It takes no props: everything comes from hooks.
// src/Page.tsx
import { useEffect, useState } from "react";
import { useApiClient, useClient, useRoute, useTheme, useUser } from "@blu/sdk";
export default function Page() {
const user = useUser();
const client = useClient(); // changes when the user switches client in Blu's header
const api = useApiClient(); // your API only, with a token for it
const route = useRoute(); // where the user is inside your module
const theme = useTheme();
const [rows, setRows] = useState<Campaign[] | null>(null);
useEffect(() => {
setRows(null);
api.get<Campaign[]>("/api/campaigns").then(setRows);
}, [api, client.id]); // refetch per client
return route.path.startsWith("/campaigns/")
? <CampaignDetail id={route.path.split("/")[2]} onBack={() => route.navigate("/campaigns")} />
: <CampaignList rows={rows} onOpen={(id) => route.navigate(`/campaigns/${id}`)} />;
}
- Data: call your API with
useApiClient()(get/post). Its errors areApiErrorwithstatusandbody. Don't callfetchwith your own token handling. - Routing: route inside the module with
useRoute():path,search,navigate(path, { replace? }). Don't read or writewindow.location; Blu owns the URL. If the app already uses a router, drive it fromuseRoute().pathandnavigate(), keeping one source of truth. - Look: use
useTheme()tokens (colours, radius, font) rather than global CSS. Don't stylehtml,bodyor:root; they're Blu's. - Events:
useEventBus()lets modules announce things (emit("pacing.flagged", {...})).
2. Vite + Module Federation
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { federation } from "@module-federation/vite";
export default defineConfig({
base: "./", // one build for every environment
plugins: [
react(),
federation({
name: "pacing", // = manifest remote.name
filename: "remoteEntry.js",
manifest: true, // emits mf-manifest.json (the manifest's remoteEntry URL)
dts: false,
exposes: { "./Page": "./src/Page.tsx" }, // = manifest remote.exposed
shared: {
react: { singleton: true },
"react-dom": { singleton: true },
"@blu/sdk": { singleton: true }, // REQUIRED: the shell provides it; two copies = no Blu context
},
}),
],
build: { target: "esnext", outDir: "dist" },
});
Dev dependencies: vite, @vitejs/plugin-react, @module-federation/vite. Deploy dist/ to the URL the manifest's remoteEntry points at, with CORS allowing the Blu shell's origins.
3. Standalone (keep the app working on its own)
// src/standalone.tsx: the entry of your own index.html
import { mountStandalone } from "@blu/sdk/dev";
import Page from "./Page";
mountStandalone({ Page });
It reads two files served next to index.html at runtime:
standalone.config.json(committed):{ "moduleId": "pacing", "title": "Campaign Pacing", "auth": { "team": { "label": "Pacing account" }, "blu": { "label": "Blu account" } } }. Drop theteamblock if the app has no sign-in of its own; dropbluto hide "Sign in with Blu".deployment.json(per environment, written by deployment):{ "stage": "dev", "apiBaseUrl": "https://pacing-api.dev…", "teamAuthUrl": "https://pacing-api.dev…/auth", "bluHostApi": "https://<this environment's Blu API>" }
"Sign in with Blu" returns only to addresses listed in the manifest's standaloneRedirectUris.
If the app already has its own standalone shell and sign-in, keep it, and wrap Page yourself only where it needs Blu's context.