Headless Themes (Advanced)
Own every pixel: normalized domain models, the useProduct controller, an adapter-based cart, and capability-gated pages.
The Principle
ThreeU is headless-first: the SDK owns data and commerce behavior; your theme owns markup and presentation. No connected component (TProductCard, TCheckoutForm) is ever the required way to build a page — each one only composes publicly-exported hooks you can use directly. Reach for the hooks and you can build a 3D product viewer, an Apple-style scroll story, or a brutalist catalog from the same data that powers a starter theme.
Ownership never changes: ThreeU owns the infrastructure, the brand owns the products/prices/inventory, and you own the experience.
Normalized Domain Models
Catalog hooks return stable `StorefrontProduct` models, not raw brand rows. The SDK's normalization layer maps the brand's messy DB shape (flat ar_name, discount_price, fixed color/size variants, {product} envelopes) onto a clean contract — so your theme never couples to the schema and keeps working when the backend evolves.
StorefrontProduct {
id, slug, type,
translations: { en: {name, description, tags}, ar: {…} },
images: [{ url, alt }],
pricing: { amount, compareAtAmount?, currency, onSale },
inventory: { available, quantity? },
options: [{ name, values: [{ value, available }] }],
variants: [{ id, sku, selections, pricing, inventory, images }],
}Pricing follows the server rule exactly (amount = discount_price ?? price), so the SDK and checkout never disagree. Import the models from threeu-sdk/theme (or threeu-sdk/domain).
The useProduct Controller
useProduct({ id | slug }) fetches a normalized product, tracks option selections → resolves the matching variant, and manages quantity. It owns no DOM and performs no cart mutation — you render everything. Note the split: product is the canonical, locale-independent model; localized is the resolved view for the active locale.
Why no monolithic component
A single <TProduct/> would decide DOM structure, layout, animation boundaries, and variant placement — a creativity ceiling. The controller hook gives you the commerce logic (valid variant, server-checked price, inventory) while you design freely.
import { useProduct, useAddToCart, TPrice } from "threeu-sdk/theme";
function ProductPage({ slug }) {
const p = useProduct({ slug });
const addToCart = useAddToCart(p);
if (p.status === "loading" || !p.localized) return <CustomSkeleton />;
return (
<motion.article>
<motion.h1>{p.localized.name}</motion.h1>
<ImmersiveGallery images={p.selectedVariant?.images ?? p.localized.images} />
{p.options.map((opt) => (
<ExperimentalSwatch key={opt.name} option={opt}
value={p.selections[opt.name]} onSelect={(v) => p.selectOption(opt.name, v)} />
))}
<TPrice pricing={p.selectedVariant?.pricing ?? p.product.pricing} />
<QuantityOrb value={p.quantity} onChange={p.setQuantity} />
<motion.button onClick={() => addToCart()}>Add to cart</motion.button>
</motion.article>
);
}Adapter-Based Cart
useCart() is storage-agnostic. Today the guest cart is client-side (the checkout takes an items[] payload); tomorrow it may be a server or cross-device customer cart. Your theme never learns the difference — it calls addItem / updateQuantity / removeItem / clear. Cart mutations live in `useCart()`, never in `useProduct()` (so bundles and service items share one cart, and product pages work without cart support). Totals are optimistic; money is authoritative only at checkout.
import { useCart } from "threeu-sdk/theme";
function MiniCart() {
const { cart, updateQuantity, removeItem } = useCart();
return (
<aside>
<span>{cart.itemCount} items · {cart.subtotal}</span>
{cart.items.map((it) => (
<div key={it.id}>
{it.name}
<input type="number" value={it.quantity}
onChange={(e) => updateQuantity(it.id, Number(e.target.value))} />
<button onClick={() => removeItem(it.id)}>×</button>
</div>
))}
</aside>
);
}
// Inject a custom adapter (e.g. a future server cart) via the provider:
// <ThemeProvider cartAdapter={myServerAdapter}>…</ThemeProvider>Capability-Based Manifest
Declare the capabilities your theme supports instead of hard-coding page logic. A capability is effective only when three independent signals agree:
effective = themeSupports && brandEnabled && platformAvailable
A theme declaring booking: true does not mean every brand sells bookings — the platform gates the route on all three. Brand enablement is derived from the storefront config (brandCapabilitiesFromConfig).
import { defineTheme } from "threeu-sdk/theme";
export default defineTheme({
name: "Aurora", slug: "aurora",
pages: { home: Home, product: Product, checkout: Checkout },
capabilities: {
catalog: { required: true, features: { variants: true, filters: true } },
checkout: { required: true, modes: ["guest"] },
services: { required: false, features: { booking: true } },
customerAccount: false,
},
});Primitives vs. Recipes
Two classes of component, and the distinction is a rule:
- Primitives are stable building blocks that solve platform concerns without dictating layout: editor-aware (
TText,THeading,TImage,TColor) and functional (TPrice,TMoney,TLocalizedText,TJsonLd). Use them anywhere. - Recipes are optional convenience components (
TProductCard,TCartSummary,TCheckoutForm) — replaceable examples, never the required path. Anything a recipe does is reachable through hooks.
Rule: no connected component may be the exclusive access point for any capability. TCheckoutForm exposes nothing useCheckout() does not.