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: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#
| Condition | Result |
|---|
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 missing | Built-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#
| Symptom | Check |
|---|
| Page renders blank | DevTools Console — usually a mount() throw |
| Page renders built-in despite BYOT install | theme_settings.external_theme.bundle_url populated? isBuiltInTheme(theme_id) returning true? |
| Bundle 404s | R2 URL stale; merchant needs to re-publish the theme version |
| Hydration mismatch | The 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