Building a theme, step by step

The contract page says what a theme may do. This one is the "now actually do it" companion, following a dark, photo-led theme that ships today. Read the contract first — every rule in it fails silently.

Design all nine surfaces before you open an editor

Decide the header, footer, home sections, listing page, product page, product card, the four money-page variants, the token palette and the settings you will expose. Changing your mind about the header once six components assume it is expensive.

Two decisions have tests attached. Pick a header positioning class no other theme uses — the end-to-end suite identifies themes by it — and put exactly one h1 on your default home page. A shipped theme once had none, which was an SEO and screen-reader defect nobody noticed by reading the markup.

Register the catalogue entry first

Append your definition to the theme catalogue: a lowercase-and-dashes id, bilingual name and description, the premium flag, a preview image path, and the settings schema.

There is no compile guard on this file. Forget it and your theme validates as "unknown", never appears in the merchant’s gallery, and produces no error anywhere. It is the first step for that reason.

Write the components

Add your id to the theme id union — from there the compiler helps you, because the registry is a total map and forgetting to register is a type error. Then create one file per slot plus an index that exports the definition. You only implement the slots you want to change; the rest fall through to the default design.

tsx
// A slot is a SERVER component. No 'use client' here, ever:
// it would put every theme's markup into every shopper's bundle.
export function LayaliFooter({locale, settings, columns}: FooterSlotProps) {
  // `locale` arrives as a prop because this renders inside a cached
  // scope — reading request data here would break the prerendered
  // shell for every shopper of every store.
  const variant = settingString(settings, 'footerVariant', 'full');

  return (
    <footer className="border-t border-[var(--border)] px-6 py-14 text-center">
      {/* Logical properties only: ms-/me-/start/end, never left/right. */}
      <div className="mx-auto flex max-w-3xl flex-col items-center gap-6">
        {variant !== 'minimal' ? <FooterColumns columns={columns} /> : null}
        <FooterLegal locale={locale} />
      </div>
    </footer>
  );
}
  • Listing and product slots are shells, not pages. They receive already-rendered nodes — filters, grid, gallery, reviews — and own the arrangement only. Reorder or omit; never re-fetch.
  • Parse settings defensively with the provided helpers. A hand-edited settings blob must never break rendering.
  • No interpolated utility classes. The CSS scanner reads source text, so a computed class name compiles to nothing. Use a fixed lookup table.
  • Logical properties only — start and end, never left and right.
A custom header still owes the shopper the language picker, the currency picker, the cart and the account link — reachable at every value of every setting the header reads, and at every breakpoint. Do not hide them behind a header-style branch, do not share their guard with the search or contact block, and do not make them desktop-only. One theme rendered its utility bar only when the merchant had typed a contact phone number, on its default style, so every new store on it shipped with no language picker at all. Arabic is the default language; a hidden picker means half the audience cannot read the shop.

Tokens

Tokens are a delta over the base stylesheet, inlined into a style element. Names must be lowercase and dashed; values are checked for characters that could break out of the declaration, and an unsafe one throws at module load in development. A typical theme overrides a handful — radius, muted, border, font.

ts
// A DELTA over the base stylesheet — not a full palette.
// The merchant's own brand colour still layers on top of this.
export const LAYALI_TOKENS = {
  '--background': '#12100e',
  '--foreground': '#f5efe6',
  '--muted': '#1c1917',
  '--border': '#2a2521',
  '--radius': '0rem',
  '--font-display': 'var(--font-cairo)',
};

Storefronts are pinned to the light palette, so only the light record actually renders. Fill in the dark one anyway, a shade deeper, so the theme is whole if dark mode ever returns.

Preview art and translations

Add a small hand-drawn wireframe SVG rather than a screenshot — it cannot go stale. Any new shopper-facing string goes into both message catalogues; reuse existing keys wherever you can rather than inventing near-duplicates.

Tests

  1. Unit: the catalogue-wide assertions — bilingual labels, valid defaults, unique ids — pick your theme up automatically. Add cases for your own settings on top.
  2. End to end: switch the fixture store to your theme, assert your header selector appears and the others do not, assert the merchant’s authored layout survived the switch, and assert the default home page has exactly one h1. If your theme is premium, assert a free-plan store is rejected instead.

Verify

A production build is the load-bearing check: it catches a client-component slot, a request-data read inside a cached scope, and every type error. A theme that builds, renders.

Then do the one check no build can do. Open your rendered header at every value of every setting it reads, at phone, tablet and desktop widths, on a store with the contact fields left empty — that is a brand-new store’s configuration — and confirm the language and currency pickers are visible and clickable in all of them. Reading the markup did not catch this last time.

A known gap to check by hand

The catalogue’s default values and the theme module’s defaults are two hand-maintained copies with no shared import and no parity test between them. If they drift, a store whose owner never opened the settings form renders with different defaults than the form displays. Pin at least one side in a test so a drift fails loudly somewhere.