How a NUMU theme actually loads and renders on a live storefront.TL;DR#
A NUMU theme is a federation bundle (theme.js + theme.css + manifest.json) that the Next.js storefront loads at runtime into a sandboxed boundary. The bundle imports React, react-dom, and @numueg/theme-sdk as bare module specifiers ā the storefront serves an import map at /__numu-runtime/import-map.json that resolves them so every theme on the platform shares the same React identity as the host.One React tree. The storefront's React and the theme's React are the same instance ā no double-render, no context boundary, no Invariant Violation: Hooks can only be called inside the body of a function component.
Themes ship small. A theme bundle only contains its own components ā React + SDK live on the host.
Hot-swap themes per merchant. The host loads a different bundle URL based on the store's theme_settings.external_theme.bundle_url.
The seven pieces#
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā 1. THEME SOURCE (developer machine, git) ā
ā src/main.tsx, src/sections/*, schemas/sections/*.json, ā
ā theme.json, locales/*.json, assets/* ā
āāāāāāāāāāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā¼
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā 2. CLI BUILD (numu-theme build) ā
ā Runs Vite with the @numueg/theme-plugin: ā
ā - contract validation (does it export `mount`?) ā
ā - schema codegen ā __generated__/sections.d.ts ā
ā - externalize React + ReactDOM + @numueg/theme-sdk ā
ā - emit manifest.json, theme.js, theme.css ā
āāāāāāāāāāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā¼
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā 3. CLI SUBMIT (numu-theme submit) ā
ā Zips dist/, uploads to NUMU-api, Celery build worker ā
ā AST-scans + re-builds in a sandbox + stores to R2. ā
ā Admin reviews ā publishes. ā
āāāāāāāāāāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā¼
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā 4. MERCHANT INSTALL (hub ā Themes ā Install) ā
ā Sets store.theme_settings.external_theme.bundle_url to ā
ā the published R2 URL. ā
āāāāāāāāāāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā¼
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā 5. PAGE REQUEST (customer visits a route) ā
ā numu-storefront server component: ā
ā - resolves store by subdomain ā
ā - fetches theme_settings ā
ā - if external_theme.bundle_url ā BYOT branch ā
āāāāāāāāāāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā¼
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā 6. BYOT BOUNDARY (ByotThemeBoundary.tsx) ā
ā Server-side renders an HTML shell with: ā
ā - <link rel="stylesheet" href={theme.css}> ā
ā - <script type="importmap" src="/__numu-runtime/..."> ā
ā - <script type="module">import('theme.js') ā
ā .then(m => m.mount({ store, themeSettings, ā
ā page, locale, currency })) ā
ā </script> ā
āāāāāāāāāāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā¼
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā 7. THEME RENDERS (in customer's browser) ā
ā mount() calls createRoot(...) and renders the theme's ā
ā component tree wrapped in <NuMuProvider> from the SDK. ā
ā The provider hydrates useShop, useCart, useCustomer, ⦠ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Why federation and not iframes#
Shopify uses Liquid ā server-rendered HTML. We considered iframes (full isolation, drop-in for any framework) and rejected them:Auth would be a nightmare. Each iframe would need its own cookie scope, CSRF token, postMessage protocol.
No shared React tree. Themes that want to add a cart drawer outside their root would need a parallel React instance.
Layout breaks. Iframe height tracking is famously fragile.
Native APIs are walled off. No clipboard, no URL.createObjectURL, no full-page navigation.
Module federation gives us isolation at the JS-bundle level (each bundle has its own component tree) without breaking the page model.Why bare specifiers + import maps#
The alternative would be having themes import from /runtime/react.js etc. ā explicit URLs. Two problems with that:1.
Theme dev environments would need to know the production URL of every shared dep. Local builds wouldn't work without a separate config.
2.
Updating React across the platform would require every theme to re-publish.
Bare specifiers (import { useState } from "react") plus an import map are the W3C standard for "this module name resolves to that URL." The storefront serves the import map dynamically:{
"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"
}
}
When the browser sees import React from "react" in the theme bundle, it follows the map and the resolved URL is the same module the host already loaded. Same module = same React instance = same hooks dispatcher.The single-React invariant#
This is the most important invariant in the theme engine. If you break it, every theme on the platform breaks with cryptic hooks errors.A theme that bundles React instead of externalizing it (CLI's build errors on this ā see contract-validate.ts).
The SDK accidentally bundling a second copy of React (we externalize it via peerDependencies + Rollup external).
The storefront ever instantiating React twice (it doesn't, but be careful with Next.js client/server boundaries).
The CLI verifies this at build time. The plugin verifies it at dev time. The federation runtime exposes registerReactSingleton() so the SDK can sanity-check the identity match.What the bundle exports#
Every NUMU theme must export a mount function and nothing else as a default behavior:// src/main.tsx
import { createRoot } from "react-dom/client";
import { NuMuProvider } from "@numueg/theme-sdk";
import App from "./App";
export function mount(context) {
const root = createRoot(document.getElementById("root"));
root.render(
<NuMuProvider {...context}>
<App />
</NuMuProvider>
);
return () => root.unmount(); // cleanup, called on theme swap
}
The contract validator in the plugin fails the build if mount is missing or not a function. The runtime expects mount to return a cleanup function (used when the customizer hot-swaps themes in preview mode).What the host injects#
The context object passed to mount is the page data contract. Documented in detail at Pages Data Contract. High-level shape:interface MountContext {
store: StoreData; // store name, currency, locale, ā¦
themeSettings: ThemeSettingsV3; // resolved settings from the customizer
page: {
type: "home" | "product" | "cart" | "checkout_*" | "account_*" | "404" | ...;
title?: string;
handle?: string; // e.g. product slug
data?: Record<string, unknown>; // page-specific (product, collection, ā¦)
};
locale: "en" | "ar";
direction: "ltr" | "rtl";
currency: string;
}
Themes never construct this object themselves ā the storefront builds it per request and passes it in.Dev-server flow#
numu-theme dev doesn't talk to NUMU-api at all. It runs a local Vite dev server (port 3001) that serves the bundle, AND it runs an internal middleware that proxies /api/v1/* to a mock backend (or your real local backend if you set NUMU_API_URL).The customizer's iframe preview mode uses the same dev URL ā you edit settings on the left, the iframe re-mounts the bundle with the new settings JSON.Production flow does NOT use this dev server. Production uses the R2-hosted built bundle.What can go wrong#
| Symptom | Likely cause |
|---|
Invariant Violation: Hooks can only be called inside the body of a function component | Two React instances. Check the theme isn't bundling React. |
useCart is not a function | SDK isn't externalized ā theme bundled its own copy. Check vite.config.ts build.rollupOptions.external. |
| Bundle loads but renders nothing | Theme didn't export mount, or mount threw. Check browser console. |
manifest.json 404 | Submit didn't complete or the R2 URL is stale. Check theme_settings.external_theme.bundle_url. |
| Styles flash unstyled | theme.css link tag not in the HTML shell. Check ByotThemeBoundary.tsx. |
| Customizer preview shows stale settings | The iframe didn't get re-mounted after the setting change. Check PreviewBridge.tsx's postMessage flow. |
Modified atĀ 2026-09-24 13:03:12