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:
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 → sliderThe 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:
categoriescollections
Publishing a collection therefore surfaces it without anyone editing a page.
Two kinds store only an id and resolve the rest at render time:
products—collectionIdfeed—feedId
Adding a block kind
- Add the name to
BLOCK_TYPESand its entry toBLOCK_REGISTRYinlib/blocks/registry.ts— label, description, icon. - Give it a defaults entry in
BLOCK_TYPE_SETTINGS(lib/blocks/settings.ts). - Write the renderer in
components/web/blocks/renderers/. - Add its
caseto the switch incomponents/web/blocks/block-renderer.tsx. - If the kind carries composition config, define its schema and a tolerant parser in
lib/blocks/composition.ts, declare its keys onblockConfigSchemain the same file, and add it toconfigByBlockKindinserver/admin/blocks/schema.ts. - Add the composer UI to
components/admin/blocks/block-form.tsx, which branches onblockTypeper kind. - Optionally add code defaults to
DEFAULT_BLOCKSandDEFAULT_LAYOUTSinlib/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 goneBoth 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:
function StreamedBlock({ children }: { children: React.ReactNode }) {
return (
<Suspense fallback={<Skeleton className="h-64 w-full" rounded="lg" />}>{children}</Suspense>
)
}Streamed through it:
productscategoriescollectionsfeedfilter
Rendered synchronously from block.config:
bannerslidernavcontentbadges
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 | bannerEach 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.