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.