Litestore · Developer guide
Storefront

Composition

Understand how Litestore builds a storefront page from layout rows and blocks, and what Save and Publish each change.

Operators build storefront pages in the admin by arranging blocks into rows. This page explains how Litestore stores those layouts, how a URL turns into a rendered page, and what you need to know before changing any of it.

A composed storefront page is an ordered list of rows. Each row has one to three cells, and each cell holds a block id.

Home page
BannerFull width
CategoriesHalf
CollectionsHalf
FilterSidebar
FeedMain
+ Add a block
In the admin, an operator arranges blocks into rows of up to three cells.

Page.layoutRows stores the geometry. The blocks are separate database rows, and components/web/blocks/block-renderer.tsx renders them.

Every composed page renders through one function, resolveStorefrontPage in server/web/pages/resolve.ts. That covers the home page, custom pages and the four index landings.

This single entry point is deliberate, and two harness checks keep it that way. If a route composes its own page, the admin editor can no longer control what that route shows.

Composed routes

COMPOSED_ROUTES declares the fixed storefront URLs whose content is composed. For each URL, it names the block set from code to use when no operator has created a Page row yet. It also gives SEO defaults for the same case:

lib/pages/composed-routes.ts
export const COMPOSED_ROUTES = {
  "/categories": {
    defaultBlocksKey: "categories",
    seo: { title: "Categories", description: "Browse every product category." },
  },
  "/products": {
    defaultBlocksKey: "collections",
    seo: {
      title: "Products & Collections",
      description: "Search products and browse curated collections.",
    },
  },
  "/collections": {
    defaultBlocksKey: "collections",
    seo: { title: "Collections", description: "Browse our product collections." },
  },
  "/feed": {
    defaultBlocksKey: "feed",
    seo: {
      title: "Feed",
      description: "The latest product news, drops, and announcements.",
    },
  },
} as const satisfies Record<string, ComposedIndexRoute>

/products and /collections share the collections default set because they showed the same content before the routes were split. Each has its own Page row, so an operator can make them different.

The home page and custom pages are composed too, but the resolver finds them by page kind instead of by URL. resolveStorefrontPage takes one of these:

{ kind: "home" }
{ kind: "custom"; slug }
{ kind: "index"; url; defaultBlocksKey; seo }

Entity routes, such as a product, a single collection or the cart, are not composed. They do not use the resolver.

The admin layout editor uses the same list. servedUrlForPage maps a Page row to the URL that actually renders it, or returns null when no route does.

The editor shows a badge on rows that return null and disables their Publish button. An operator cannot publish a layout that no route serves.

Seeding page rows

ensureComposedIndexPages in server/admin/pages/composed.ts creates a Page row for each URL in COMPOSED_ROUTES that does not have one yet. It fills the new row with the layout from code for that URL's defaultBlocksKey.

It also fixes an older Categories row that is still tagged page: "custom" by giving it its real family.

The function is idempotent, because it only creates what is missing. That lets the editor call it every time it loads. If two editors load at once, both may try to create the same row and hit the unique url/slug. The function swallows the create error so neither load throws.

FAMILY_BY_URL maps the four URLs to the families category, collection and search.

The family must not be home or custom. servedUrlForPage routes those two families elsewhere. A composed index page tagged custom would report its URL as /pages/{slug}, and the editor would disable its Publish button.

The feed exception

/feed gets extra handling.

buildFeedPageRows adds a filter block at the start and a feed block at the end. The feed block is bound to ensureDefaultFeed(). The function reuses existing feed and filter blocks instead of creating duplicates.

A separate function, ensureFeedPage, does the same for the public feed route the first time someone visits it.

Layout rows

A row describes geometry and nothing else. lib/pages/layout.ts is the only file that defines columns, rows and width:

lib/pages/layout.ts
export const MAX_LAYOUT_COLUMNS = 3

export const layoutRowSchema = z.object({
  id: z.string().min(1),
  /** 1–3 cells; each is a block id or null (empty cell renders nothing). */
  cells: z.array(z.string().nullable()).min(1).max(MAX_LAYOUT_COLUMNS),
  minHeight: z.number().int().min(1).max(2000).optional(),
  width: z.enum(["contained", "full"]).optional(),
})

minHeight is a minimum in pixels, not a fixed height. Content can grow taller. If you leave it out, the row takes its natural height.

width defaults to contained, which keeps the row inside the edges of the layout container. full stretches the row to the edges of the viewport.

Why parsing is row by row

parseLayoutRows checks each row on its own and keeps the valid ones. It deliberately does not call layoutRowsSchema.safeParse(rows) on the whole array.

If it parsed all rows at once, a single bad row would produce a blank page:

one off-shape row
    ↓
whole-array parse fails
    ↓
no rows
    ↓
wantsDefaultBlocks serves code-default blocks
    ↓
operator sees their published layout ignored, with no error

The likely cause in practice is a schema change. If someone tightened layoutRowSchema, an all-at-once parse would invalidate every stored layout in every store at the same moment.

Dropping rows without saying so is a problem of its own, so the resolver reports it. When a page has published rows but some of them did not render, ResolvedStorefrontPage.layoutDegraded is set to { saved, rendered }.

The composed routes show this to admins only, as a message like "2 of 5 rows rendered" with a link to edit the page. Customers never see it.

Which blocks a layout uses

layoutBlockIds collects the distinct block ids that a layout uses, skipping empty cells.

The resolver uses this list to fetch blocks. getPagePublishBlock in lib/pages/page-lifecycle.ts uses the same list to decide whether a page can be published.

If a page uses no blocks, or uses blocks that no longer exist, getPagePublishBlock returns a string that explains why publishing is blocked. Otherwise it returns null.

Save and publish

Save and Publish write to separate columns on Page. The difference that matters is which column the storefront reads.

Save calls savePageLayoutDraftRows:

draftLayoutRows          ← rows
cache                    ← nothing invalidated

The storefront never reads a draft, so there is no cache to clear.

Publish calls publishPageLayoutRows:

layoutRows               ← rows
draftLayoutRows          ← rows
cache                    ← invalidateTag(CACHE_TAGS.blocks)
server/admin/pages/layouts.ts
export async function publishPageLayoutRows(
  pageId: string,
  rows: LayoutRow[],
  ctx: { user?: { id: string } | null },
): Promise<void> {
  await pageCrud.update(
    pageId,
    { layoutRows: rows, draftLayoutRows: rows },
    { user: ctx.user ?? undefined },
  )
  // Storefront grids read Page.layoutRows + blocks under the blocks tag.
  invalidateTag(CACHE_TAGS.blocks)
}

Publish goes through pageCrud.update. That way the edit is written to the activity log and the page caches are cleared together. Publish then clears the storefront blocks tag. Save writes the draft column directly.

findPageLayoutEditorRows returns both sets of rows:

  • rows is the draft the editor works on. If no draft exists yet, it falls back to the published rows.
  • publishedRows is the published version. The editor compares against it to decide whether Publish has anything new to promote.

findBlockOptions lists every block for the cell pickers. Blocks have no lifecycle of their own. A block goes live when an operator places it in a published layout.

Save changes nothing on the storefront

Save writes draftLayoutRows and clears no cache. If you edit and save a page without publishing it, the storefront shows exactly what it showed before. No storefront code reads the draft column. Only Publish copies rows into layoutRows and invalidates CACHE_TAGS.blocks.

Code defaults

DEFAULT_BLOCKS and DEFAULT_LAYOUTS in lib/blocks/default-blocks.ts seed exactly four placements: homepage, categories, collections and feed.

lib/blocks/default-blocks.ts
export const DEFAULT_LAYOUTS: Record<
  "homepage" | "categories" | "collections" | "feed",
  string[][]
> = {
  homepage: [
    ["Bring softness, texture, and quiet tech into every room"],
    ["Homepage quick nav"],
    ["Softness you can feel", "Ambient light and sound", "Curated collections"],
    ["The season's centerpiece"],
    ["Gifts they'll actually keep", "Just landed"],
  ],
  categories: [["Shop by category"], ["All categories"]],
  collections: [["Shop the collections"], ["Collections"]],
  feed: [["Latest arrivals & bestsellers"], ["Free shipping over $50"], ["Feed promo chips"]],
}

These layouts refer to blocks by name, and the seed step turns each name into an id. If no block has that name, the cell is left empty instead of raising an error.

The default blocks contain only text and links. They have no images and no layout. Operators add images to each block in the editor, and Litestore stores them as MediaAssociation rows.

Block composition config

The layout decides where blocks go. Block.config decides what each block puts together inside itself.

lib/blocks/composition.ts has a tolerant parser for each block kind:

  • parseFilterBlockConfig
  • parseNavBlockConfig
  • parseFeedBlockConfig
  • parseProductsBlockConfig
  • parseContentBlockConfig
  • parseBadgesBlockConfig

All of them share one permissive schema for writes:

lib/blocks/composition.ts
export const blockConfigSchema = z.object({
  facets: z.array(filterFacetSchema).optional(),
  mode: z.enum(["auto", "manual"]).optional(),
  overrides: z.record(z.string(), facetOverrideSchema).optional(),
  showSort: z.boolean().optional(),
  items: z.array(navItemSchema).optional(),
  navLayout: z.enum(["top", "left"]).optional(),
  collectionId: z.string().min(1).optional(),
  feedId: z.string().min(1).optional(),
  content: z.array(contentBlockItemSchema).optional(),
  badges: z.array(badgeItemSchema).optional(),
  settings: blockSettingsSchema.optional(),
})

This is one permissive object, not a z.union of one schema per kind. Each per-kind schema would fill in defaults, so a union could match {facets} against the wrong kind and drop the facets.

When a key is missing from this schema, zod strips it at the server-action boundary, and the save quietly deletes it. This is why the Appearance controls did nothing until settings was added.

Two of the parsers check each item separately instead of the whole config. For example, parseBadgesBlockConfig drops one malformed badge instead of emptying the whole block. This follows the same rule as parseLayoutRows: one bad entry should not take down everything around it.

Discipline checks

Two rules in scripts/check-discipline.ts make sure composed routes go through the resolver. Both scan app/(web)/**/page.tsx, and each has a baseline file.

storefront-composition-authority

This rule fails a storefront route that uses any of the resolver's internals:

getDefaultBlocks
resolveLayoutRows
CompositionTemplate

Only server/web/pages/resolve.ts is allowed to use them.

The rule's fix hint tells you to call resolveStorefrontPage({ route }) and to use resolveStorefrontPageSeo in generateMetadata. If you need a new kind of page, it tells you to add a new index route kind to the resolver instead of building a separate composition path for one route.

composed-route-uses-resolver

The second rule catches a case the first cannot see: a route that skips composition entirely. It asks one question of each file.

Does this page route call resolveStorefrontPage at all?

The scan is a single line:

scripts/check-discipline.ts
scanFile: src => {
  if (/discipline-ok/.test(src)) return []
  return /\bresolveStorefrontPage\b/.test(src) ? [] : [1]
},

Every storefront page route must call the resolver unless it is on the allowlist as an entity route. The allowlist is explicit:

product/        collections/[    categories/[
feed/           cart/            dashboard/
orders/         messages/        wishlist/
auth/           invite/          unauthorized/
blog/           help/            updates/

Each of these routes renders a single thing rather than a page an operator composes, so it is allowed to own its markup.

The rule exists because /products once shipped a hand-built CollectionsLanding component. Nothing read that page's layout, so publishing it reported success and changed nothing on the storefront.

Where a change belongs

If you are changing geometry, such as row order, number of cells, minimum height, or contained versus full width, change the row array.

If you are changing what one block puts together inside itself, change Block.config. Remember to declare any new key on both blockConfigSchema and configByBlockKind, or a save will delete it.

Only change DEFAULT_BLOCKS and DEFAULT_LAYOUTS to adjust how a store looks before an operator has built anything.

Don't add composition logic to the route itself. Both checks on this page exist to prevent one specific failure:

An operator can edit and publish a page, but the storefront never reads the published rows.

On this page