NUMU Docs
Contact
APIThemes
Partner Apps
APIThemes
Partner Apps
  1. Storefront host
  • 🗂️ 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. Storefront host

BYOT fork

How every storefront route decides between rendering a theme bundle vs. a built-in fallback.

The pattern#

Every route does (paraphrased):
const store = await fetchStoreByDomain(domain);
const themeSettings = await fetchThemeSettings(store.id);

if (
  themeSettings.external_theme?.bundle_url &&
  !isBuiltInTheme(themeSettings.theme_id)
) {
  return (
    <ByotThemeBoundary
      bundleUrl={themeSettings.external_theme.bundle_url}
      cssUrl={themeSettings.external_theme.css_url}
      themeSettings={themeSettings}
      storeData={store}
      page={{ type: "product", title: product.name, data: { product } }}
    />
  );
}

// Built-in path
return <BuiltInProductDetail product={product} />;

resolveByotFork()#

src/lib/byot-fork.tsx consolidates the boilerplate:
import { resolveByotFork } from "@/lib/byot-fork";

const fork = await resolveByotFork(domain, {
  type: "product",
  title: product.name,
  handle: slug,
  data: { product },
});

if (fork.kind === "missing-store") notFound();
if (fork.kind === "byot")          return fork.element;
return <BuiltInProductDetail product={product} />;   // built-in branch
The helper returns one of three shapes:
type ByotForkResult =
  | { kind: "missing-store" }                      // 404
  | { kind: "byot"; element: ReactElement; store; theme }
  | { kind: "builtin"; store; theme: ThemeSettingsV3 | null };
Most routes use the helper. The older routes (cart, PDP, home — written before Phase 7) do the resolution inline; refactoring them is a low-priority cleanup.

<ByotThemeBoundary>#

Lives at src/components/theme-engine/ByotThemeBoundary.tsx. The SSR-rendered HTML it emits:
The component also:
Injects <html dir="rtl"> when store.default_language is ar
Falls back to <NoThemeBoundary> (a plain "theme not configured" page) when the bundle URL is reachable but mount() throws
Captures errors in a top-level boundary so a broken theme doesn't take down the whole tree

When the BYOT branch fires#

ConditionResult
external_theme.bundle_url exists AND theme_id is NOT a built-in (bazar, minimal, …)BYOT
external_theme.bundle_url exists but theme_id is "bazar" (etc.)Built-in (treats bazar as built-in even with a bundle URL — defensive)
external_theme.bundle_url missingBuilt-in
themeSettings is null (fresh store, never customized)Built-in (with theme: null)
isBuiltInTheme() in ThemeRegistry.ts lists the known built-in ids. Currently just bazar.

Built-in fallback hierarchy#

When the BYOT branch doesn't fire, the route renders:
1.
Built-in template if themeSettings.templates[page_type] exists — uses PageTemplateRenderer to dispatch sections from the built-in theme's registry
2.
Built-in component if no template — hard-coded React component (e.g. BuiltInProductDetail, BuiltInCart)
3.
Generic message if neither — No {route} template configured placeholder (vanishingly rare path)
The 2 → 3 fallback shouldn't fire often; we've upgraded the most common paths (PDP, cart, account/orders/[id], account/gift-cards) to use built-in components rather than generic placeholders.

Why pre-fetch data SSR#

We could let themes fetch everything client-side (e.g. PDP calls useProduct() which hits /api/products/{slug}). We don't, because:
1.
SEO. Search crawlers see the product's structured data + title in the first byte, not after a JS round-trip.
2.
LCP. First-paint hits the product image immediately; no waterfall.
3.
404 handling. Wrong slug → notFound() at the route level, instant. Client-side fetch would render an empty shell first.
4.
JSON-LD. We emit <script type="application/ld+json"> from the route — the bundle never sees the LD blocks.
The cost is a bit of duplication: themes could still call useProduct() if they wanted, and the SDK would return the data without a round-trip (the provider hydrates from page.data).

Customizing the boundary#

If a theme needs to register a service worker, lazy-load a font, etc., the boundary supports an optional head prop:
<ByotThemeBoundary
  ...
  head={[
    <link key="font" rel="preload" href="..." as="font" crossOrigin="anonymous" />,
  ]}
/>
Themes can also inject <head> content from inside their mount() by manipulating document.head — but the early-head approach via the boundary is preferred (it's in the SSR'd HTML, before the bundle loads).

Direction (RTL)#

The boundary sets <html dir="rtl"> when the store's default_language is ar OR the request's locale cookie is ar. Themes don't need to do anything special — CSS logical properties (margin-inline-start etc.) will Just Work.
If a theme uses physical properties (margin-left), it must check useDirection() and flip manually:
const dir = useDirection();
const marginSide = dir === "rtl" ? "marginRight" : "marginLeft";
Better: use logical properties. The lint rule for this is in the Phase 3.7 RTL audit (not yet shipped).

Debugging#

SymptomCheck
Page renders blankDevTools Console — usually a mount() throw
Page renders built-in despite BYOT installtheme_settings.external_theme.bundle_url populated? isBuiltInTheme(theme_id) returning true?
Bundle 404sR2 URL stale; merchant needs to re-publish the theme version
Hydration mismatchThe bundle wrote different DOM than the SSR'd shell. The boundary's #numu-root is empty SSR, so this is usually a <head> mismatch (e.g. theme injects a <style> server-side that doesn't match client)
Modified at 2026-09-19 15:53:15
Previous
Built-in fallbacks
Next
Routing
Built with