.dev
Storefront templates

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 request

Customers 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.

PlaneWhat it isWho writes itWhat breaks if you confuse it
SchemaThe editor catalog: page list, layouts, built-in component types, root propsSchemaYou, in schema.ts, merged with Lithium's built-ins at buildThe merchant editor shows the wrong pages or inspectors
DataTemplateData: the instance tree (pages → sections → components)The compiler, from pages/, props.json, locales/The shop renders the wrong tree
LibraryOptional blocks in library/components/YouDrag-drop catalog is empty; the live page is unaffected until a merchant inserts one
Runtime referenceA template_components row addressed by refIdPublish, when shared and page customs are uploadedA type: "reference" node renders blank if the row is missing
RendererLithium's registry + react-liveFeeefYou 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:

FieldOwnerLives
propsMerchantValues the inspector edits
code, propsSchema, slotsSchema, slotsLayoutYouThe component's identity
slotsMerchantWhich blocks sit in which named hole
childrenMerchantOnly 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.

  • children is for layout types (grid, flex, container). The parent is a box. The children are what the box stacks.
  • slots are named holes on a custom component (content, success, stateSlot). Your App() decides where props.slots.content is painted. slotsSchema tells the editor the hole exists. slotsLayout tells the editor how to arrange the holes on small and large screens. slotsLayout is 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, including props.slots
  • React.useState — prefer the React. form so the editor scope always has the hook
  • t("home.heroHeading") — strings from locales/*.json
  • useStore(), useFeeefCart(), useCurrentProduct(), useFeeef() — each can be null
  • Link and useRouter() — in-app navigation. A raw <a href="/products"> reloads the document
  • useSlotContext("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 $refRuntime reference
Where you write it{ "$ref": "shared.header" } in a placement JSONProduced at publish, or stored as type: "reference" + refId
When it resolvesBuild. The node is copied into dist/data.jsonRequest. Lithium asks the API to resolve refId
After buildNo $ref remainsThe id remains until render
Use it forHeader, footer, order fields you share across pagesMarketplace 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

Name
Phone
State
City

Address stays in this slot. It appears after state and city are both set.

Total4 900 دج
Buy it now
−1+
Add to cart

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 no pages.product, the route renders an empty main.
  • You cannot invent a registry type and have it render. Use custom, 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_copyrights link is not a shippable theme.

Where to go next

You want to…Read
Build one from zeroCreate a template
Field-by-field JSONData model
Scope API and TSXCustom components
Folders, $ref, importsTemplate kit
COD behaviorOrder forms
Sell itMarketplace

The same ideas, written as engineering notes: the blog.

On this page