.dev
Storefront templates

Custom components

Author React components for themes — the injected scope API, styling rules, and TypeScript with npm imports.

A custom component is a node with type: "custom" whose code string contains a React component. Lithium precompiles it server-side and evaluates it with react-live, injecting a rich scope — so your code needs no imports at runtime and can still use the SDK, cart, routing and i18n.

Authoring shape

You author flat TSX files; the kit compiles them into the code string. The contract:

const propsSchema = {
  heading: { type: "string", name: "Heading" },
} as const;

export const meta = {
  type: "custom",
  title: "Hero",
  order: 0,
  propsSchema,
  props: { heading: "" },
} as const satisfies FeeefComponentMeta<typeof propsSchema>;

type Props = FeeefLivePropsOf<typeof propsSchema>;

function App() {
  const p = props as Props;
  return <h1>{p.heading || t("home.heroHeading")}</h1>;
}

Rules that matter:

  • The entry point is always function App().
  • Use React.useState / React.useEffect (namespaced) rather than bare hooks.
  • Metadata lives in export const meta — no separate JSON manifest.
  • The published code string contains no import/export; the build strips or bundles them. Your source may import freely (see below).
  • Type props with FeeefLivePropsOf<typeof propsSchema> and cast: const p = props as Props.

Injected scope

Everything available inside App() without imports:

SymbolPurpose
ReactHooks, Fragment
propsInstance props (plus props.slots)
useStore(){ store } or nullalways null-check
useFeeefCart(){ cart, count, store } or null — the cart service
useCurrentProduct(){ product } on product pages
useFeeef()SDK instance or nullcall it as a hook
useTemplate()Read-only template state
useTheme()next-themes (dark/light/system)
t(key, params?), useFeeefT(), useFeeefLocale()Theme i18n from locales/*.json
Link, useRouter, usePathname, useSearchParamsnext/navigation — never <a> for in-app nav
RouterNavClickable block with stopPropagation for nested cards
SlotContextBridge, useSlotContext(key)Pass context between a shell and its slot children
SlotsLayoutRenders props.slotsLayout with props.slots
dartColorToCssColorConvert Flutter color ints coming from props

Slots in JSX

{props.slots?.header}
<main>{props.slots?.body}</main>
{props.slots?.footer}

Prefer <SlotsLayout /> when the node has slotsLayout — merchants can then rearrange responsive slot order in the editor without code changes.

Styling rules

  • Brand: hsl(var(--primary)), hsl(var(--primary-foreground)), hsl(var(--background))
  • Corners: var(--corners-card), var(--corners-buttons-large)
  • Dark mode rides the native tokens (:root / .dark) — never build a parallel palette.
  • Never read store.decoration.primary directly (it's a Flutter 0xAARRGGBB int).
  • Store chrome from store fields: store.logoUrl, store.name, store.contacts.

Working with data

// Cart
const cartCtx = useFeeefCart();
if (!cartCtx) return null;
cartCtx.cart.addItem({ product, quantity: 1, variantPath: "Color/Red" });

// Product media: product.photoUrl is a string, product.media is string[] of URLs
// Product lists: server-side filters via flat params or the filterator
const ff = useFeeef();
const res = await ff.products.list({
  params: { store_id: store.id, in_stock: true, order_by: "sold:desc", limit: 8 },
});
const products = res?.data ?? res ?? [];

Product/category listing recipes (related products, price ranges, search grids) follow the filterator conventions. Categories accept flat params only (store_id, parent_id).

Orders from theme code are submitted through the storefront's server route (POST /api/orders/create), never straight from the browser to the API — the built-in order form components handle this for you.

TypeScript and npm imports

Your source files are real modules — local imports and npm packages both work:

import clsx from "clsx";
import { bannerLabel } from "./labels";
import { FoButton } from "../../../shared/ds/ui";

function App() {
  return <div className={clsx("hero")}>{bannerLabel("Feeef")}</div>;
}

The build bundles everything into the single code string:

  • Install deps at the theme root (npm install clsx) — the compiler resolves from the theme's node_modules.
  • react / react-dom stay external — the runtime injects React. Don't import React from "react"; add @types/react only as a dev dependency for types.
  • No Node builtins or server-only packages — the code runs in the shopper's browser.
  • Keep export const meta in the entry file.

When to use a folder

Only when the component needs slots/, children/, or colocated helper modules:

pages/product/components/product-shell/
  product-shell.tsx          # export const meta + App (includes slotsSchema)
  slots/header/header.json   # $ref or leaf node
  slots/body/gallery.tsx
  slots/form/order.json      # built-in type placement

Never create both hero.tsx and hero/ for the same id.

Anti-patterns

  • code / propsSchema / slots* nested inside props — breaks renderer and editor.
  • Bare useState instead of React.useState.
  • <a href> for internal navigation — use Link / RouterNav.
  • Hardcoded colors instead of CSS variables.
  • Regenerating instanceIds on edit.
  • Treating product.media as { url }[] — it is string[].

On this page