This is the practical guide to building a Kytelo theme. It walks through
themes/zephyr — the theme this platform ships and dogfoods — piece by
piece, then covers the constraints every theme is authored under and why
they exist.
If you haven't read the platform overview yet, start there: sections, page documents, and the "everything is serializable data" rule are assumed knowledge below.
What a theme actually is#
A theme (@kytelo/theme-react, today's only shipping engine) is four
things sealed together by defineTheme():
- A manifest — name, version, the section types it provides, its theme-wide settings schema, and its bundled template paths.
- A layout — the component that wraps every page (header, footer, where plugin-contributed layout sections like the cart drawer render).
- Sections — the building blocks a merchant arranges into pages.
- Templates — the default page documents used until a merchant customizes a page (or forever, for templates they never touch).
// themes/zephyr/src/index.ts
export const zephyrTheme = defineTheme({
manifest: zephyrManifest,
layout: DefaultLayout,
sections: [hero, productGrid, productDetail],
templates,
});
defineTheme() parses the manifest, checks the engine kind matches
(@kytelo/theme-react refuses to seal a liquid-engine manifest), and
structurally validates every bundled template document. It throws
ThemeManifestError with every problem listed if something's wrong —
you'll see this at import time, not at runtime in front of a customer.
The manifest#
// themes/zephyr/src/manifest.ts
export const zephyrManifest: ThemeManifest = {
name: 'zephyr',
version: '0.1.0',
engine: { kind: 'react', kytelo: '^0.1.0' },
layout: 'default',
sections: ['hero', 'product-grid', 'product-detail'],
settingsSchema: [
{
id: 'brand',
label: 'Brand',
fields: [
{ id: 'logoImage', label: 'Logo', type: 'image' },
{
id: 'accentColor',
label: 'Accent color',
type: 'color',
default: '#38bdf8',
},
{
id: 'announcement',
label: 'Announcement',
type: 'text',
default: 'Free shipping on your first order',
},
],
},
],
templates: {
index: 'templates/index.json',
product: 'templates/product.json',
collection: 'templates/collection.json',
cart: 'templates/cart.json',
search: 'templates/search.json',
},
};
name must be kebab-case, version an exact semver, and
engine.kytelo a semver range checked against @kytelo/theme-kit at
boot. sections lists section types the theme itself provides —
plugin-contributed sections (a cart drawer, search results) don't belong
here, even though a theme's templates can reference them.
A section with a loader#
product-grid fetches real data. Its loader runs server-side, inside the
storefront route's own loader — it receives the section instance's
validated settings, the relevant route params, and data: the same
KyteloDataApi a plugin filter or job would get, tenant-scoped
automatically by the kernel.
// themes/zephyr/src/sections/product-grid.tsx (abridged)
export const productGrid = defineSection<ProductGridData>({
schema: {
type: 'product-grid',
label: 'Product grid',
settings: [
{ id: 'collection', label: 'Collection', type: 'collection_ref' },
{ id: 'limit', label: 'Limit', type: 'number', default: 8 },
],
},
loader: async ({ settings, params, data }) => {
const collectionId = settings['collection'] as string | undefined;
const limit = (settings['limit'] as number | undefined) ?? 8;
const products = await data.products.listActive({
collectionId,
collectionSlug: collectionId ? undefined : params['slug'],
limit,
});
// ...enrich each product with its first image + lead variant price...
return { products: items };
},
component: ProductGridSection,
});
Note the fallback: when a merchant hasn't set an explicit collection,
the loader falls back to params['slug'] — the collection route param —
so the same section works both as "the featured grid on the homepage"
(explicit collection) and "the grid on a collection page" (implicit, from
the URL). A section with no loader at all (hero, settings-only) just
renders straight from settings — there's no rule that every section
needs one.
SectionLoaderArgs.params is a plain Record<string, string> — route
params plus whatever the storefront host injects (the cart plugin's
sections read a cartToken this way; see
plugin development).
Layout and useStorefront()#
Section and layout components never import app code — no server
functions, no router. They call useStorefront() for cart actions
instead, and the storefront app supplies the real implementation:
// themes/zephyr/src/layout.tsx (excerpt)
const { actions } = useStorefront();
// ...
<button onClick={() => actions.openCart()}>{/* cart icon */}</button>;
useStorefront() returns { actions, cartOpen }, where actions is:
interface StorefrontActions {
addToCart(variantId: string, qty?: number): void | Promise<void>;
updateCartLine(lineId: string, qty: number): void | Promise<void>;
removeCartLine(lineId: string): void | Promise<void>;
openCart(): void;
closeCart(): void;
}
Outside a <StorefrontProvider> (in a test, or a future theme-preview
harness) every action is a no-op — sections render fine, they just don't
do anything, which is exactly what you want in isolation.
Settings schemas#
Every settings field — theme-wide, section, or block — is one of nine
field types, each with its own default shape:
| type | value type | notes |
| ---------------- | ---------- | -------------------------------------------------------- |
| text | string | multiline?: boolean |
| richtext | string | |
| number | number | optional min / max / step |
| boolean | boolean | |
| select | string | options: { value, label }[], value must be one of them |
| color | string | |
| image | string | a storage path/URL |
| product_ref | uuid | |
| collection_ref | uuid | |
Field ids must be identifiers (/^[a-zA-Z][a-zA-Z0-9_]*$/). Admin
renders a form from this schema automatically — you never hand-write a
settings UI, in a theme or a plugin.
Templates#
A theme bundles one default PageDocument per template kind it supports
(index is mandatory; product, collection, cart, search, page
are the others). A page document is sections keyed by instance id, plus
the render order:
// themes/zephyr/src/templates.ts (index template)
const indexTemplate: PageDocument = {
version: 1,
template: 'index',
sections: {
welcome: { type: 'hero', settings: {} },
featured: { type: 'product-grid', settings: { limit: 8 } },
},
order: ['welcome', 'featured'],
};
Sections that support blocks add blocks (keyed by block instance id) and
blockOrder:
main: {
type: 'product-detail',
settings: {},
blocks: {
title: { type: 'title', settings: {} },
price: { type: 'price', settings: {} },
'buy-buttons': { type: 'buy-buttons', settings: {} },
description: { type: 'description', settings: {} },
},
blockOrder: ['title', 'price', 'buy-buttons', 'description'],
},
Notice product.json's related section instance is type: 'related-products' — a type zephyr itself doesn't provide. That's legal:
template validation at author time only checks document structure;
whether related-products actually resolves to a real section (a plugin
contributing it) is checked when the full registry — this theme plus
whatever plugins a tenant has installed — composes at boot.
Authoring constraints#
Themes are a constrained, statically-authored surface — not a
sandbox in the "arbitrary code runs safely" sense (yet), but a surface
narrow enough that a theme which passes its checks cannot accidentally
reach outside ctx.data/settings, and one that tries to on purpose leaves
an obvious, reviewable diff. Three layers enforce this:
1. The import allowlist. A theme's source may only import:
| Import | Why |
| ------------------------------ | ------------------------------------------------------------------------------------- |
| @kytelo/theme-react | the renderer contract (defineSection, defineTheme, useStorefront, ThemeImage) |
| @kytelo/theme-kit | the engine-agnostic contract types |
| @kytelo/plugin-sdk | data/record types (ProductRecord, PricedCart, ...) |
| react, react/jsx-runtime | the rendering library itself |
| relative imports (./, ../) | the theme's own other files |
Everything else — @kytelo/db, @kytelo/core, @kytelo/ui, any
specific @kytelo/plugin-* package, Node builtins (node:*, and their
unprefixed aliases like fs/path/crypto) — is disallowed. A theme
reaches plugin-contributed sections by referencing a section type
string in a page document, never by importing the plugin package.
2. No fetch/storage/process. Sections access data only through
ctx.data (the loader's data argument) and settings — never a direct
network call, browser storage, or environment/process access. fetch,
XMLHttpRequest, WebSocket, EventSource, localStorage,
sessionStorage, indexedDB, process, and require are all rejected,
alongside dynamic import(), new Function(...), and reading or writing
document.cookie. Everything a section needs — including tenant identity
— arrives through the arguments the platform already gives it.
Why: this is tier 1 of the sandboxing roadmap. Today it's enforced statically (the two layers above). Marketplace theme submissions add AI-assisted review on top. Runtime isolation (WASM for hot-path functions, isolates for richer theme/plugin code) is the long-term tier — none of it changes what a theme is allowed to do, only how strictly that gets checked.
3. The validator. ESLint (no-restricted-imports /
no-restricted-globals / no-restricted-syntax, packages/config/eslint/theme.mjs)
is fast, IDE-visible defense in depth — but it can only deny known-bad
patterns, not express a true allowlist. The authoritative check is a
dedicated validator that scans a theme's actual import specifiers against
the allowlist above:
pnpm validate-theme <theme-dir> # e.g. themes/zephyr
It runs five checks — loading and re-validating the theme's manifest, the import allowlist scan, ESLint with the theme preset, and a structural check (every declared section type is actually provided, every bundled template validates against the composed registry, settings groups are well-formed) — and exits non-zero if any of them fail, printing PASS/FAIL per check.
Where to go from here#
- Read the rest of
themes/zephyr/src/sections/for the remaining section (product-detail) and its block-driven rendering. - Plugin development — the other half of what ends up in a theme's rendered page: sections a plugin contributes.
- Local development — run a theme against the real storefront to see it end to end.