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.
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:
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 → sliderThe 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
- Register the kind. Add its name to
BLOCK_TYPESand its entry toBLOCK_REGISTRYinlib/blocks/registry.ts, with a label, description and icon. - Give it default settings. Add an entry to
BLOCK_TYPE_SETTINGSinlib/blocks/settings.ts. - Write the renderer in
components/web/blocks/renderers/. - Add a
casefor it to the switch incomponents/web/blocks/block-renderer.tsx. - Declare its config, if it has any. Define the config schema and a tolerant parser in
lib/blocks/composition.ts. Declare its keys onblockConfigSchemain the same file. Then add the kind toconfigByBlockKindinserver/admin/blocks/schema.ts. - Add the composer UI to
components/admin/blocks/block-form.tsx, which has a branch onblockTypefor each kind. - Optionally add code defaults to
DEFAULT_BLOCKSandDEFAULT_LAYOUTSinlib/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 goneBoth 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:
function StreamedBlock({ children }: { children: React.ReactNode }) {
return (
<Suspense fallback={<Skeleton className="h-64 w-full" rounded="lg" />}>{children}</Suspense>
)
}These kinds stream through it:
productscategoriescollectionsfeedfilter
These kinds render straight away from block.config:
bannerslidernavcontentbadges
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 | bannerBLOCK_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.