How the template system works
The concept behind Lithium themes — why a storefront is a document, and how schema, data, the editor, and the renderer stay apart.
This guide includes Lithium-specific examples. For a Helium theme, start with the Helium runtime and migration guide; verify runtime-specific imports, page IDs and browser behavior.
I work on the storefront at Feeef. A shop here is Lithium, already running. What changes from shop to shop is a JSON document called TemplateData. That document is the theme: pages, sections, and component instances, including custom React stored as a string of code.
If you remember one sentence from this page, remember this: you author files, the compiler emits a document, Lithium renders the document, and the merchant edits the document. Code and content never share a field. I keep repeating it because the folder you edit looks like an app, and the thing the shop loads is a file.
The rest of this page is that sentence unpacked, with the sharp edges I still hit. The create guide is the same system as a sequence of commands. The blog is me telling the same stories with less of a table.
Why a document
Merchants install a theme, change a heading, reorder a form field, and switch to dark mode without a git repo. Developers still want TypeScript, imports, and a diff. Those two requirements only fit if the thing that ships is data, and the thing you edit is source that compiles into that data.
pages/hero.tsx ──build──▶ dist/data.json ──publish──▶ store.metadata.templateData
│
└── Lithium reads it on every requestCustomers never download your repository. They hit {slug}.feeef.store, Lithium loads the
store, and getTemplate(store) returns the document to render.
Five planes
People mix these up because several of them are named "schema" or "template". They are different objects with different owners.
| Plane | What it is | Who writes it | What breaks if you confuse it |
|---|---|---|---|
| Schema | The editor catalog: page list, layouts, built-in component types, root propsSchema | You, in schema.ts, merged with Lithium's built-ins at build | The merchant editor shows the wrong pages or inspectors |
| Data | TemplateData: the instance tree (pages → sections → components) | The compiler, from pages/, props.json, locales/ | The shop renders the wrong tree |
| Library | Optional blocks in library/components/ | You | Drag-drop catalog is empty; the live page is unaffected until a merchant inserts one |
| Runtime reference | A template_components row addressed by refId | Publish, when shared and page customs are uploaded | A type: "reference" node renders blank if the row is missing |
| Renderer | Lithium's registry + react-live | Feeef | You cannot add a new type string and expect it to render |
Schema answers "what can be edited". Data answers "what is on this page right now". The renderer answers "how does this node become React".
schema.ts replaces the page map when you provide pages. It does not deep-merge
with Lithium's default pages. A theme whose overlay only lists home publishes a schema
that only knows home. The blank starter lists home, product, and thank_you for
that reason.
What a component node is
Every block in the tree is the same shape. Layout, a built-in, and your hero are not
different file formats. They are nodes that differ by type.
{
type: "custom", // or "grid", "product_body", "reference", …
instanceId: "hero_home", // stable. The editor and React keys use it.
title: "Hero", // label in the editor, not on the shop
props: { heading: "" }, // merchant content
propsSchema: { heading: { type: "string", name: "Heading" } },
code: "function App() { … }",
slots: null,
slotsSchema: null,
}Ownership is strict:
| Field | Owner | Lives |
|---|---|---|
props | Merchant | Values the inspector edits |
code, propsSchema, slotsSchema, slotsLayout | You | The component's identity |
slots | Merchant | Which blocks sit in which named hole |
children | Merchant | Only on grid, flex, and container |
code is not a prop. If you put code or propsSchema inside props, the inspector and
the renderer both miss them. That mistake is the most common way a generated theme looks
fine in a JSON viewer and blank in the shop.
instanceId must stay stable across edits. Regenerating it on every save makes the editor
lose its selection and makes React remount the block.
Two trees that look similar
Slots and children are both arrays of nodes. They are not interchangeable.
childrenis for layout types (grid,flex,container). The parent is a box. The children are what the box stacks.slotsare named holes on a custom component (content,success,stateSlot). YourApp()decides whereprops.slots.contentis painted.slotsSchematells the editor the hole exists.slotsLayouttells the editor how to arrange the holes on small and large screens.slotsLayoutis editor chrome. It is not the shop layout unless your component reads it (or you render<SlotsLayout />).
A product page that needs a gallery beside a form is a shell component with slots. A hero
that is just a heading is a flat file. Do not invent a folder of nested shells when a list
of siblings under pages/<page>/components/ is enough. Siblings compile into
sections.main in order.
Custom code is a string with a sandbox
You write this:
export const meta = { type: "custom", title: "Hero", propsSchema, props: { heading: "" } };
function App() {
const p = props as Props;
return <h1>{p.heading || t("home.heroHeading")}</h1>;
}The shop runs something closer to this:
function App() {
const p = props;
return React.createElement("h1", null, p.heading || t("home.heroHeading"));
}The build strips export, bundles local imports into that function, and leaves react
external. Lithium precompiles the string per route (compiledCode) so the browser does
not run a transpiler. Evaluation still happens in a sandbox: there is no import, no
fs, and no second copy of React.
The sandbox injects a scope. The names you can call without importing them are listed in Custom components. The ones that matter on day one:
props— this instance, includingprops.slotsReact.useState— prefer theReact.form so the editor scope always has the hookt("home.heroHeading")— strings fromlocales/*.jsonuseStore(),useFeeefCart(),useCurrentProduct(),useFeeef()— each can be nullLinkanduseRouter()— in-app navigation. A raw<a href="/products">reloads the documentuseSlotContext("pdp")/SlotContextBridge— how an order-form shell shares cart and form state with field components that are separate nodes
useFeeef() is a hook. Calling the client as a plain function is a bug. Submitting an
order with ff.orders.send from the browser is also a bug: production forms POST
/api/orders/create. See Order forms.
Compile, then two publishes
npm run dev watches the folder, rebuilds, and PUTs the document to
/stores/:id/template-preview. That writes metadata.templatePreview only. The live
document, metadata.templateData, stays whatever customers already see. The CLI opens
https://{slug}…/?preview={token}. Middleware sets an httpOnly cookie so the next clicks
(home, product, checkout) keep the draft. The catalog — products, prices, stock — is the
real store. Exit with the banner or /?preview=0.
npm run publish -- --apply is the step that changes what customers see. A publish
without that flag updates the listing and the release. The install question defaults to
no, so Enter leaves the previous theme in front of customers. The full flag table is
Develop and publish.
Hosted shops read drafts from the production API. If your CLI is still pointed at
http://127.0.0.1:3333, the preview URL opens and shows the live theme, because the
shop never sees the draft you wrote locally. Point feeef config set apiUrl at the same
API the shop uses.
$ref is not reference
These two mechanisms share a word and nothing else.
Kit $ref | Runtime reference | |
|---|---|---|
| Where you write it | { "$ref": "shared.header" } in a placement JSON | Produced at publish, or stored as type: "reference" + refId |
| When it resolves | Build. The node is copied into dist/data.json | Request. Lithium asks the API to resolve refId |
| After build | No $ref remains | The id remains until render |
| Use it for | Header, footer, order fields you share across pages | Marketplace components and the rows publish creates so page JSON does not embed every function body |
If you clone a public theme and leave reference nodes unresolved, local preview renders
empty holes, because those ids belonged to the source store. feeef template init from a
free public template inlines them before unpacking so the folder stands alone.
What the merchant can change without you
Because propsSchema is data, the Flutter editor builds an inspector from it: string,
text, number, boolean, select, slider, color, image. You do not ship a settings screen.
Because fields of a form are sibling nodes (or slot children) with a hide boolean, the
merchant can hide email without deleting it, and can move the buy button above the phone
field. Hiding is props.hide === true → render null. The node stays in the tree, in
the same place, so turning it back on restores it.
The blank product page is that idea made concrete. Visible by default: name, phone, a
state/city row, address (painted only after both are chosen), a buy button with the
total, and a separate quantity + add-to-cart row. Offers, email, delivery type, note, and
custom fields are in the same list with hide: true.
Editor slots
Address stays in this slot. It appears after state and city are both set.
That widget is a model of the contract, not a store. The real fields are the
blank-order-* components the blank kit compiles.
What you do not control from a theme
- Lithium's routes (
/p/:id,/checkout,/thanks) exist whether or not your schema lists the page. If the document has nopages.product, the route renders an empty main. - You cannot invent a registry
typeand have it render. Usecustom, or a built-in listed in the schema (product_body,product_order_form,thank_you, …). - Prices on screen use the currency symbol from
store.configs.currencies(دج,$). The order API still wants an ISO code. See Theming. - A footer without a visible
feeef.org/?ref=footer_copyrightslink is not a shippable theme.
Where to go next
| You want to… | Read |
|---|---|
| Build one from zero | Create a template |
| Field-by-field JSON | Data model |
| Scope API and TSX | Custom components |
Folders, $ref, imports | Template kit |
| COD behavior | Order forms |
| Sell it | Marketplace |
The same ideas, written as engineering notes: the blog.