Litestore · Developer guide

Blocks

Learn how storefront pages resolve to typed blocks, what the ten block kinds do, and every file adding an eleventh touches.

A composed storefront page is rows of blocks. A block is a self-contained widget stored as a Block row and rendered by components/web/blocks/block-renderer.tsx.

Layout — rows, cells, widths — lives in the page template (lib/pages/layout.ts and the admin layout editor), never in the block.

That split is the point. A block does not know how wide it is or what sits beside it, so the same block can be placed anywhere without carrying a copy of the page it was authored for. For the merchandising side, see the operator guide.

Block types

Ten block types are declared as one ordered tuple, and both BlockType and the registry derive from it:

lib/blocks/registry.ts
export const BLOCK_TYPES = [
  "banner",
  "slider",
  "products",
  "feed",
  "categories",
  "collections",
  "badges",
  "nav",
  "content",
  "filter",
] as const

export type BlockType = (typeof BLOCK_TYPES)[number]

One tuple, two derivations. BlockType is the union of its members, and BLOCK_REGISTRY is declared as const satisfies Record<BlockType, BlockDefinition> — so a type added to the tuple without a label, description and icon fails typecheck.

Each definition is metadata for the admin surfaces only. The block picker, the table and the layout editor read it; the storefront never does. Nothing a customer sees depends on the registry entry.

Legacy stored types

Six older stored types still exist in databases. resolveBlockKind maps a stored type to the widget kind that renders it:

hero       → banner
editorial  → banner
banners    → banner
card       → banner
imageCards → banner
carousel   → slider

The five that resolve to banner were the same promo widget at different template sizes.

Mapping on read rather than rewriting rows means those pages keep rendering without a data migration.

What a block stores

Two kinds resolve their content on read:

  • categories
  • collections

Publishing a collection therefore surfaces it without anyone editing a page.

Two kinds store only an id and resolve the rest at render time:

  • productscollectionId
  • feedfeedId

Adding a block kind

  1. Add the name to BLOCK_TYPES and its entry to BLOCK_REGISTRY in lib/blocks/registry.ts — label, description, icon.
  2. Give it a defaults entry in BLOCK_TYPE_SETTINGS (lib/blocks/settings.ts).
  3. Write the renderer in components/web/blocks/renderers/.
  4. Add its case to the switch in components/web/blocks/block-renderer.tsx.
  5. If the kind carries composition config, define its schema and a tolerant parser in lib/blocks/composition.ts, declare its keys on blockConfigSchema in the same file, and add it to configByBlockKind in server/admin/blocks/schema.ts.
  6. Add the composer UI to components/admin/blocks/block-form.tsx, which branches on blockType per kind.
  7. Optionally add code defaults to DEFAULT_BLOCKS and DEFAULT_LAYOUTS in lib/blocks/default-blocks.ts.

Steps 1 and 2 are the ones the compiler will not let you skip. BLOCK_TYPE_SETTINGS is a full Record<BlockType, Partial<BlockSettings>>, so a missing entry is a typecheck failure rather than a runtime surprise.

Step 7 seeds the four composed index surfaces — homepage, categories, collections and feed — before an operator has authored a page.

The two steps that fail silently

Steps 4 and 5 are on you, not on the compiler.

The renderer switch ends in default: return null. A missing case renders nothing and raises no error, so a kind that is registered, settable and authorable can still produce a blank row on the storefront.

Step 5 has two separate places to forget, and each one loses the config quietly:

block form saves config

key not declared on blockConfigSchema

stripped by zod at the server-action boundary

kind absent from configByBlockKind

composition keys dropped by cleanBlockConfig on save

row saves successfully, config is gone

Both structures are Partial/permissive by design, which is what makes the omission survive a save instead of throwing.

Rendering

Blocks render on the server through the same query modules as the rest of the storefront.

The five data-fetching kinds are wrapped in a Suspense boundary with a skeleton fallback:

components/web/blocks/block-renderer.tsx
function StreamedBlock({ children }: { children: React.ReactNode }) {
  return (
    <Suspense fallback={<Skeleton className="h-64 w-full" rounded="lg" />}>{children}</Suspense>
  )
}

Streamed through it:

  • products
  • categories
  • collections
  • feed
  • filter

Rendered synchronously from block.config:

  • banner
  • slider
  • nav
  • content
  • badges

The second set is deliberately not wrapped. A boundary around a component that never suspends buys nothing and only costs an extra fallback commit.

Presentation is a fixed vocabulary

Presentation is not per-block styling. blockSettingsSchema has exactly four keys:

align        start | center
showTitle    true | false
columns      2 | 3 | 4
imageAspect  square | card | banner

Each type exposes the subset listed for it in BLOCK_TYPE_SETTINGS, so the admin form is generated from that map rather than written per kind.

There is no per-block colour field. Renderers read theme tokens, and scripts/check-theme-boundaries.ts fails the build on a hard-coded colour.

Keeping composition consistent

Every new kind of section is added as a new block kind, so all storefront content stays composable and editable rather than accruing hand-rolled sections that an operator cannot touch.

Composed URLs are declared once, in lib/pages/composed-routes.ts.

Two discipline rules keep routes on the resolver

scripts/check-discipline.ts carries both. storefront-composition-authority fails a app/(web)/**/page.tsx that imports resolver internals (getDefaultBlocks, resolveLayoutRows, CompositionTemplate) instead of calling resolveStorefrontPage; composed-route-uses-resolver catches the route that bypasses composition entirely — publishing reports success and changes nothing.

The question to ask before hand-writing a section into a storefront route is not "can I render this here?" It is:

Should an operator be able to move, retitle or remove this without a deploy?

If the answer is yes, it is a block; if it is geometry, it belongs to the layout.

Next Steps

On this page