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

Architecture

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.
This means:
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.
What breaks it:
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).
See BYOT Contract for the full surface.

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#

SymptomLikely cause
Invariant Violation: Hooks can only be called inside the body of a function componentTwo React instances. Check the theme isn't bundling React.
useCart is not a functionSDK isn't externalized — theme bundled its own copy. Check vite.config.ts build.rollupOptions.external.
Bundle loads but renders nothingTheme didn't export mount, or mount threw. Check browser console.
manifest.json 404Submit didn't complete or the R2 URL is stale. Check theme_settings.external_theme.bundle_url.
Styles flash unstyledtheme.css link tag not in the HTML shell. Check ByotThemeBoundary.tsx.
Customizer preview shows stale settingsThe iframe didn't get re-mounted after the setting change. Check PreviewBridge.tsx's postMessage flow.
Modified atĀ 2026-09-24 13:03:12
Previous
Glossary
Next
BYOT contract
Built with