NUMU Docs
Contact
APIThemes
Partner Apps
APIThemes
Partner Apps
  1. Theme engine
  • 🗂️ Themes overview
  • 🚀 Start here
  • Glossary
  • Theme engine
    • Architecture
    • BYOT contract
    • Customizer
    • Federation runtime
    • Page data contract
    • Theme manifest
  • Theme SDK
    • Components
    • Federation helpers
    • Hooks
    • SDK overview
    • Type definitions
  • CLI & Vite plugin
    • CLI commands
    • CLI overview
    • Lint rules
    • Section library
    • Vite plugin
  • Storefront host
    • API proxies
    • Built-in fallbacks
    • BYOT fork
    • Routing
    • Storefront overview
  1. Theme engine

Federation runtime

How the storefront makes theme bundles share React, ReactDOM, and the SDK with the host without ever loading them twice.

The import map#

Served at /__numu-runtime/import-map.json by the storefront. Generated dynamically per request so the URLs match the storefront's deployment:
{
  "imports": {
    "react":                          "/__numu-runtime/react.js",
    "react/jsx-runtime":              "/__numu-runtime/react-jsx-runtime.js",
    "react-dom":                      "/__numu-runtime/react-dom.js",
    "react-dom/client":               "/__numu-runtime/react-dom-client.js",
    "@numueg/theme-sdk":                "/__numu-runtime/sdk.js",
    "@numueg/theme-sdk/jsx-runtime":    "/__numu-runtime/sdk-jsx-runtime.js"
  }
}
Injected into the page HTML by ByotThemeBoundary.tsx:
The browser's native import-map machinery resolves the bare specifiers in theme.js before fetching. No bundler-level shim, no runtime resolver — just W3C.

The runtime endpoints#

Each URL under /__numu-runtime/ serves a single module. They're plain ESM:
Same pattern for ReactDOM, the SDK, JSX runtimes.
The storefront bundles these via Next.js + Vite's separate runtime-build step. We use named-export wrappers rather than re-exporting the whole module because some named exports (React internals like __CLIENT_INTERNALS_*) are needed by the SDK's useSyncExternalStore shim.

Why not Module Federation (webpack)#

Webpack's official Module Federation plugin works but ties us to webpack. Vite has unofficial plugins but they break in subtle ways with Vite 5+. Native import maps are simpler, smaller, and the W3C standard.

React identity verification#

The SDK exports registerReactSingleton() and getReactSingleton(). On mount, the SDK checks:
import { React } from "./internals";
import { getReactSingleton, registerReactSingleton } from "./federation";

const existing = getReactSingleton();
if (existing && existing !== React) {
  throw new Error(
    "Multiple React instances detected. The theme bundle is shipping its own React copy — externalize it."
  );
}
registerReactSingleton(React);
This catches the most common build mistake: forgetting to externalize React in the theme's vite.config.ts. The plugin's contract-validate.ts runs the same check at build time so you don't ship a broken theme.

How a theme externalizes React#

In your theme's vite.config.ts:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import numuTheme from "@numueg/theme-plugin";

export default defineConfig({
  plugins: [react(), numuTheme()],
  build: {
    lib: {
      entry: "src/main.tsx",
      formats: ["es"],
      fileName: "theme",
    },
    rollupOptions: {
      external: [
        "react",
        "react-dom",
        "react/jsx-runtime",
        "react-dom/client",
        "@numueg/theme-sdk",
      ],
    },
  },
});
The numu-theme-plugin injects these externals automatically if you forget. Either way, the resulting theme.js has top-level import { useState } from "react" lines that the browser resolves via the import map.

Bundle URL resolution#

The storefront fetches theme_settings.external_theme.bundle_url from NUMU-api. For marketplace themes, this is an R2 URL like:
https://r2.numueg.app/themes/<theme_id>/<version_id>/theme.js
CORS is configured on R2 to allow *.numueg.app for GET only. Themes load via <script type="module">, which respects CORS — without the CORS header the bundle silently fails to import.

Dev-time differences#

In numu-theme dev:
The bundle URL is http://localhost:3001/theme.js (Vite dev server)
The import map points to http://localhost:3001/runtime/* (the plugin's middleware serves them)
HMR works because Vite's dev server handles the WebSocket protocol on the same port
When you load the customizer with the bundle in dev mode (point theme_settings.external_theme.bundle_url to http://localhost:3001/theme.js), edits in your editor hot-reload the iframe within ~100ms.
Local dev needs 127.0.0.1.nip.io
Browsers won't load http://localhost:3001/theme.js from a page on http://test.localhost:3000/ due to cross-origin restrictions on certain CORS edge cases. The plugin's mkcert-based HTTPS option (or using *.nip.io) sidesteps this.

What lives in the runtime bundle vs the SDK#

The runtime endpoints are kept minimal:
React, ReactDOM, JSX runtimes
@numueg/theme-sdk (everything theme code imports)
That's it. Anything else a theme needs (lodash, framer-motion, etc.) must be bundled into theme.js itself.
This keeps the runtime cacheable across every theme on the platform — one set of URLs, one CDN cache key, every theme benefits.

Cleanup on theme swap#

The customizer can hot-swap themes in preview mode. When that happens:
1.
The page calls the previous bundle's mount()-returned cleanup function: cleanup()
2.
That cleanup calls root.unmount()
3.
The page replaces the <script type="module"> tag with a new URL
4.
Browser fetches the new bundle, calls mount(newContext)
5.
New root mounts
Themes that allocate global resources (intervals, listeners on window, etc.) must clean them up in the returned cleanup function, otherwise they leak across swaps. The SDK's NuMuProvider handles its own internals automatically.

Debugging#

SymptomProbable causeWhere to look
Failed to resolve module specifier "react"Import map didn't load (network error, MIME-type issue, parser ran before the map)DevTools → Network for import-map.json; check <script type="importmap"> is BEFORE the theme <script type="module">
Cannot read properties of undefined (reading 'useState')The runtime bundle was served as wrong MIME type (e.g. text/html 404 page)Network tab → check the Content-Type header on /__numu-runtime/react.js
Two copies of React loadedTheme bundled React instead of externalizingDevTools Sources → search for react.production.min — should appear once
SDK hooks return undefinedNuMuProvider not wrapping the treeAdd a <NuMuProvider> at the root of your mount() render
HMR not picking up section changesPlugin's WebSocket isn't running (dev server crashed)Check numu-theme dev console
Modified at 2026-09-19 15:52:00
Previous
Customizer
Next
Page data contract
Built with