Litestore · Developer guide
Storefront

Blocks

See how storefront pages are built from blocks, what each block kind does, and which files you change to add a new kind.

Blocks are the pieces operators place on storefront pages, such as a banner, a product grid or a filter. Read this page when you want to change how a block renders or add a new kind of block.

A composed storefront page is made of rows of blocks. A block is a self-contained widget. Litestore stores it as a Block row, and components/web/blocks/block-renderer.tsx renders it.

your-store.com
Nav
Free shipping30-day returnsSecure checkout
A storefront page is a stack of blocks, such as a banner, a product grid and a set of categories.

The page template handles layout, meaning rows, cells and widths. It is defined in lib/pages/layout.ts and edited in the admin layout editor. Blocks never contain layout.

This split is intentional. A block doesn't know how wide it is or what is next to it. You can therefore place the same block anywhere without it carrying a copy of the page it was first made for. For how operators use blocks to merchandise the store, see the operator guide.

Block types

Litestore declares ten block types in one ordered tuple. Both BlockType and the registry are derived 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]

BlockType is the union of the tuple's members. BLOCK_REGISTRY is declared as const satisfies Record<BlockType, BlockDefinition>. If you add a type to the tuple without giving it a label, description and icon, typecheck fails.

The registry entries are metadata for the admin only. The block picker, the blocks table and the layout editor read them. The storefront never reads the registry, so nothing a customer sees depends on it.

Older stored types

Databases may still contain six older block types. resolveBlockKind maps each stored type to the widget that renders it:

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

The five types that map to banner were all the same promo widget at different template sizes.

Litestore maps these types when it reads them instead of rewriting the rows. Pages that use them keep rendering without a data migration.

What a block stores

The categories and collections kinds look up their content each time they are read. When an operator publishes a collection, it appears in these blocks without anyone editing a page.

The products and feed kinds store only an id and look up everything else when they render. A products block stores a collectionId, and a feed block stores a feedId.

Adding a block kind

  1. Register the kind. Add its name to BLOCK_TYPES and its entry to BLOCK_REGISTRY in lib/blocks/registry.ts, with a label, description and icon.
  2. Give it default settings. Add an entry to BLOCK_TYPE_SETTINGS in lib/blocks/settings.ts.
  3. Write the renderer in components/web/blocks/renderers/.
  4. Add a case for it to the switch in components/web/blocks/block-renderer.tsx.
  5. Declare its config, if it has any. Define the config schema and a tolerant parser in lib/blocks/composition.ts. Declare its keys on blockConfigSchema in the same file. Then add the kind to configByBlockKind in server/admin/blocks/schema.ts.
  6. Add the composer UI to components/admin/blocks/block-form.tsx, which has a branch on blockType for each kind.
  7. Optionally add code defaults to DEFAULT_BLOCKS and DEFAULT_LAYOUTS in lib/blocks/default-blocks.ts.

The compiler won't let you skip steps 1 and 2. BLOCK_TYPE_SETTINGS is a full Record<BlockType, Partial<BlockSettings>>, so a missing entry fails typecheck instead of surprising you at runtime.

Step 7 fills the four composed index pages, homepage, categories, collections and feed, before an operator has built them.

The two steps that fail silently

Nothing checks steps 4 and 5 for you.

The renderer switch ends in default: return null. If you forget the case, the block renders nothing and no error appears. A kind can be registered, have settings and be editable in the admin, and still show up as a blank row on the storefront.

Step 5 has two places you can forget, and each one loses the config without telling you:

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 or permissive on purpose. That is why the save succeeds instead of throwing when something is missing.

Rendering

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

The five kinds that fetch data render inside a Suspense boundary that shows a skeleton while they load:

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>
  )
}

These kinds stream through it:

  • products
  • categories
  • collections
  • feed
  • filter

These kinds render straight away from block.config:

  • banner
  • slider
  • nav
  • content
  • badges

The second group is deliberately left unwrapped. A component that never suspends gains nothing from a boundary, and the boundary only adds an extra fallback commit.

A fixed set of presentation options

Blocks don't have free-form styling. blockSettingsSchema has exactly four keys:

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

BLOCK_TYPE_SETTINGS lists which of these keys each type offers. The admin generates the settings form from that map, so nobody writes a form by hand for each kind.

Blocks have no colour field. Renderers use theme tokens, and scripts/check-theme-boundaries.ts fails the build if it finds a hard-coded colour.

Keeping composition consistent

Every new kind of section becomes a new block kind. That way all storefront content stays composable and editable, and the storefront doesn't collect hand-built sections that an operator can't change.

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

Two discipline rules keep routes on the resolver

Both rules are in scripts/check-discipline.ts. storefront-composition-authority fails an app/(web)/**/page.tsx that imports resolver internals (getDefaultBlocks, resolveLayoutRows, CompositionTemplate) instead of calling resolveStorefrontPage. composed-route-uses-resolver catches a route that skips composition entirely, where publishing reports success but changes nothing.

Before you write a section by hand into a storefront route, don't ask whether you can render it there. Ask this:

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

If the answer is yes, make it a block. If it is geometry, it belongs in the layout.

Next Steps

On this page