TYPESAFETY

WHY STRICT ZOD SCHEMAS ARE THE ONLY WAY TO SCALE A PAGE-BUILDER

Every page-builder starts strict and ends as `any`. Here's the small set of rules that keep Hotrod's block system honest as it grows.

Jono 13 May 2026 5 min read

If you’ve ever inherited a page-builder, you know the smell. The first three blocks are beautifully typed. The fourth one accepts a data: any prop “for now.” The fifth one is a copy-paste of the fourth with one field renamed. By block fifteen the schema is a Record<string, unknown>, every component starts with // @ts-expect-error, and the only person who knows what the CMS will actually send is the developer who left two months ago.

Hotrod is designed so that doesn’t happen. The page-builder is built around a handful of rules that sound boring and turn out to be load-bearing.

Rule 1: every block is a Zod schema first

A block in Hotrod isn’t a component file. It’s a folder under src/blocks/ with two siblings:

  • schema.ts — a Zod object describing the block’s data shape
  • index.astro — the component that renders that data

The schema is the contract. Everything else — TypeScript types, content validation, agent prompts, error messages — falls out of it. We don’t write a separate TypeScript type and pray it stays in sync. The component receives z.infer of its own schema and we’re done.

Rule 2: blocks live in a discriminated union

src/blocks/registry.ts collects every block schema into one discriminated union keyed by type. That single union is what content.config.ts uses to type the blocks field on the pages collection.

What that buys you, in practice:

When an MDX page has blocks: [{ type: "hero", title: "..." }], Astro’s content layer validates it against the union at build time. If hero requires a title and the page is missing one, the build fails with the file name, the block index, and the missing field. If the page references type: "heeero", the discriminator catches it as an invalid value and lists every valid block type alongside the error. The agent gets a runnable suggestion. No silent rendering of a half-built page.

This is the property that makes the system scale. Adding a block doesn’t loosen anything. Every new schema joins the union; every consumer of the union is checked again automatically.

Rule 3: no load-bearing optionals

We have a memory pinned in the project that says: strict Zod schemas, no load-bearing optionals, loud build-time failures over silent fallbacks. That’s not aesthetic preference — it’s what keeps the page-builder from rotting.

The temptation, every single time you add a new block, is to mark fields optional “for flexibility.” The hero’s subtitle is optional. The card grid’s cta is optional. The pricing block’s featuredIndex is optional. Six months later, the component is a maze of ?. chains and “what if this is undefined” branches, and nobody can tell which field is genuinely optional and which one is “we just didn’t fill it in yet.”

Hotrod’s rule: if rendering the block correctly requires the field, the field is required. If it really is optional (an avatar image, a secondary link), it’s optional and the component has a defined behaviour for its absence. There is no third state.

The same rule applies to the content collections themselves. The blog schema declares title, description, publishedAt, and author as required. Forget one, and the build refuses to start with an error that names the file. The site never serves a post with a missing title because the build that would have produced it never finished.

Rule 4: schema errors are the agent’s primary feedback loop

Agents debug from error output. Vague errors waste tokens. Specific errors are gold.

Every required string in Hotrod’s schemas uses a helper that produces messages like:

const requiredString = (field: string) =>
  z.string({ message: `\`${field}\` is required.` })
   .min(1, { message: `\`${field}\` must not be empty.` });

The reason is mundane and important. When an agent edits an MDX file and runs astro build, it reads the error message and tries again. A message that says \`title\` is required. produces a correct second attempt. A message that says ZodError: Invalid input produces a guess.

Reserve your specificity budget for the schema error layer. It pays for itself every day.

Rule 5: blocks register, don’t import

When you add a new block, you do exactly two things:

  1. Create the folder with schema.ts and index.astro.
  2. Add one line to src/blocks/registry.ts.

That’s it. The discriminated union picks it up. The <PageSections> renderer iterates the typed array and dispatches on type. There’s no central component file that grows with the number of blocks. There’s no map of strings to components that needs to stay in sync. The registry is the map.

This is the property that lets a coding agent add a block on its own. It doesn’t have to grep the codebase for “where do I register this.” The path is mechanical, documented, and short.

The boring truth

None of this is exciting. Discriminated unions, required fields, registry maps, specific error strings. Page-builders rot because the boring discipline is exactly the part that gets dropped first when the team is shipping.

The reason Hotrod can stay small and stay correct is that we treat the schema layer the way other projects treat tests: as the thing you don’t compromise on, even when you’re tired and it’s Friday.

If you’re building a page-builder of your own, the rules above port directly. Start strict. Stay strict. Refuse the optional. Let the build complain.

Jono

Founder, Roboto Studio

Builds open-source tools for agent-driven websites.