Litestore · Developer guide

Composition

Learn how composed storefront URLs resolve through one resolver, how layout rows store geometry, and what Save and Publish each write.

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

The geometry lives in Page.layoutRows. The blocks are separate rows, rendered by components/web/blocks/block-renderer.tsx.

Every composed surface — home, custom pages, and the four index landings — renders through one function: resolveStorefrontPage in server/web/pages/resolve.ts.

That single entry point is deliberate, and two harness checks exist to keep it that way. A route that composes its own page is a route whose canvas the admin editor can no longer control.

Composed routes

COMPOSED_ROUTES declares the fixed storefront URLs whose content is composed. Each entry names the code-default block set to use when no admin-authored Page row exists, plus 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 rendered identical content before the routes were split. They are separate Page rows, so an operator can diverge them.

Home and custom pages are composed too, but they are addressed by page kind rather than by URL. resolveStorefrontPage takes one of:

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

Entity routes — a product, one collection, the cart — are not composed and do not use the resolver.

The same list drives the admin layout editor. servedUrlForPage maps a Page row to the URL that actually renders its canvas, or null when nothing does.

The editor badges null rows and disables their Publish, so an operator cannot publish a layout that no route serves.

Seeding page rows

ensureComposedIndexPages (server/admin/pages/composed.ts) creates a Page row for every URL in COMPOSED_ROUTES that lacks one, using the code-default layout for its defaultBlocksKey.

It also relabels an older Categories row still tagged page: "custom" to its real family.

It is idempotent — it creates only what is absent — so the editor calls it on every load. create failures are swallowed so a concurrent editor load racing on the unique url/slug does not throw.

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

The family must not be home or custom. servedUrlForPage routes those two elsewhere, so a composed index page tagged custom would report its served URL as /pages/{slug} and get its Publish disabled.

The feed exception

/feed gets extra treatment.

buildFeedPageRows prepends a filter block and appends a feed block bound to ensureDefaultFeed(), reusing existing feed and filter blocks rather than minting duplicates.

A separate ensureFeedPage does the same for the public feed route on first visit.

Layout rows

A row is pure geometry. lib/pages/layout.ts is the only place columns, rows and width live:

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 grows past it, and omitting it means natural height.

width defaults to contained — the layout container's edges. full breaks out to the viewport edges.

Why parsing is row by row

parseLayoutRows validates row by row and keeps the good ones. It deliberately does not call layoutRowsSchema.safeParse(rows).

An all-or-nothing parse turns one bad row into 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 realistic trigger is schema evolution. Tightening layoutRowSchema would otherwise invalidate every stored layout in every store at once.

Dropping rows quietly is its own hazard, so the resolver reports the shortfall. ResolvedStorefrontPage.layoutDegraded is { saved, rendered } when a page has published rows but some did not render.

The composed routes surface it to admins only — "2 of 5 rows rendered" with an edit link, never shown to customers.

Which blocks a layout references

layoutBlockIds collects the distinct non-null block ids a layout references.

The resolver uses it to fetch blocks. getPagePublishBlock (lib/pages/page-lifecycle.ts) uses the same list as the publish gate.

A page with no referenced blocks, or one referencing blocks that no longer exist, returns a blocking reason string instead of null.

Save and publish

The two write paths are separate columns on Page, and the difference is what the storefront reads.

Save calls savePageLayoutDraftRows:

draftLayoutRows          ← rows
cache                    ← nothing invalidated

The storefront never reads a draft, so there is nothing to bust.

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 so the edit is activity-logged and page caches invalidate as one unit, then busts the storefront blocks tag. Save writes the draft column directly.

findPageLayoutEditorRows returns both sets:

  • rows — the draft the editor edits, falling back to the published rows when no draft exists yet
  • publishedRows — the baseline that decides whether Publish has anything to promote

findBlockOptions lists every block for the cell pickers. Blocks have no lifecycle of their own, so placing one in a layout is what makes it live.

Save changes nothing on the storefront

Save writes draftLayoutRows and busts no cache. A page edited and saved but not published renders exactly as it did before, and the storefront has no code path that reads the draft column. Only Publish promotes rows into layoutRows and invalidates CACHE_TAGS.blocks.

Code defaults

DEFAULT_BLOCKS and DEFAULT_LAYOUTS (lib/blocks/default-blocks.ts) seed four placements and no more — 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"]],
}

Layouts reference blocks by name, resolved to ids at seed time. A name with no matching block becomes an empty cell rather than an error.

The default blocks carry text and links only — no images and no layout. Images are MediaAssociation rows added per block in the editor.

Block composition config

Layout owns geometry. Block.config owns everything a block composes internally.

lib/blocks/composition.ts holds a tolerant parser per kind:

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

They all share one permissive write schema:

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

It is one permissive object rather than a z.union of per-kind schemas. A union of defaulting schemas would match {facets} against the wrong branch and drop them.

A key not declared here is stripped by zod at the server-action boundary and silently deleted on save. That is what made the Appearance controls a placebo until settings was added.

Two parsers are tolerant per item rather than per config. parseBadgesBlockConfig drops one malformed badge instead of emptying the block — the same blast-radius rule as parseLayoutRows.

Discipline checks

Two rules in scripts/check-discipline.ts keep composed routes on the resolver. Both scan app/(web)/**/page.tsx and both carry a baseline file.

storefront-composition-authority

This one fails a storefront route that references the resolver's internals:

getDefaultBlocks
resolveLayoutRows
CompositionTemplate

Only server/web/pages/resolve.ts is allowlisted.

Its fix hint points at resolveStorefrontPage({ route }) plus resolveStorefrontPageSeo for generateMetadata, and says to add a new index route kind to the resolver rather than a per-route composition path.

composed-route-uses-resolver

The sibling rule catches what the first cannot see: a route that bypasses composition entirely. It asks one question of each file.

Does this page route call resolveStorefrontPage at all?

Its scan is one line:

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

So every storefront page route must call the resolver unless it is allowlisted as an entity route. The allowlist is explicit and reasoned:

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

Each of those renders one thing rather than an operator-composed page, so it legitimately owns its markup.

The rule exists because /products once shipped a hand-rolled CollectionsLanding whose page canvas nothing read: publishing that page reported success and changed nothing on the storefront.

Where a change belongs

Change the row array when the change is geometry — order, cell count, minimum height, contained versus full width.

Change Block.config when the change is what one block composes internally, and remember that a new key has to be declared on blockConfigSchema and on configByBlockKind to survive a save.

Change DEFAULT_BLOCKS and DEFAULT_LAYOUTS only for what a store should look like before an operator has authored anything.

Add nothing to the route itself. The failure this page's two checks exist to prevent has one shape:

A page an operator can edit and publish, whose published rows the storefront never reads.

On this page