Modeling Page-Builder Blocks with Discriminated Unions

This guide, part of Content Modeling Best Practices, shows how to model the blocks of a page builder so that the frontend can treat them as a discriminated union: a set of object types that share one literal field, usually _type or __typename, whose value tells the compiler and the renderer exactly which shape the rest of the object has.

Page builders are where headless content models are most flexible and where frontends most often break. Editors can add, reorder and remove blocks freely, and each block has its own fields. Without a clear discriminator, components guess at shapes with optional chaining and fallbacks. With one, every block is checked once at the boundary and then handled with full type safety all the way to the component.

One discriminator, three consumersThe block's type field is read by the runtime validator to pick the right schema, by the TypeScript compiler to narrow the block's props, and by the renderer's registry to choose the component.Block from CMS_type: 'hero'Runtime validatorpicks hero schemaTypeScriptnarrows to HeroBlockRegistryrenders <Hero>
The same literal field drives validation, type narrowing and dispatch, so the three can never disagree.

The Problem

A marketing site’s page builder had grown to fourteen block types over three years. The CMS returned them in a sections array, but the frontend’s type for a section was a single interface with every field from every block marked optional. Components received the whole object and checked for the fields they needed. When a block type gained a field with the same name as a field in another block but a different shape, image as a string URL in one and an asset object in the other, the testimonial component started rendering broken images on pages where editors had used the new block. Nothing failed at build time, because the loose type allowed both.

How Discriminated Unions Work

A discriminated union is a union of object types that all have one property with a distinct literal type. In TypeScript, checking that property narrows the union to exactly one member:

  • { _type: "hero"; headline: string; image: Asset }
  • { _type: "quote"; text: string; author: AuthorRef }
  • { _type: "cta"; label: string; href: string }

Inside if (block._type === "quote"), the compiler knows block.text exists and block.headline does not. A switch over _type with no default branch, or a mapped registry type, fails compilation if a member is not handled. Runtime validation libraries such as Zod and Valibot support the same idea, choosing the schema by the discriminator instead of trying each one in turn, which gives clearer errors and faster parsing.

The CMS side of the contract is simple: every block type in the model must produce a fixed, unique discriminator value. Most platforms do this already. Sanity adds _type to every object in an array, Contentful exposes __typename in GraphQL and sys.contentType.sys.id in REST, Storyblok uses component, and Strapi dynamic zones use __component. The job is to make the frontend rely on it consistently.

The discriminator field by platformThe field that identifies a block's type in each CMS and how its value is formed.CMSDiscriminatorExample valueSanity_typeheroContentful GraphQL__typenameComponentHeroContentful RESTsys.contentType.sys.idcomponentHeroStoryblokcomponentheroStrapi dynamic zone__componentblocks.heroHygraph__typenameHero
Normalize these to one field name at the fetch boundary so components never see platform differences.

Implementation

Define the union once with a runtime schema library and derive the TypeScript type from it, so validation and types cannot drift. Normalize the platform’s discriminator into _type in the same step.

TypeScript
// lib/blocks/schema.ts
import { z } from "zod";

const Asset = z.object({ url: z.string().url(), alt: z.string().default(""), width: z.number(), height: z.number() });

export const HeroBlock = z.object({
  _type: z.literal("hero"),
  _key: z.string(),
  headline: z.string().max(60),
  image: Asset,
});
export const QuoteBlock = z.object({
  _type: z.literal("quote"),
  _key: z.string(),
  text: z.string(),
  author: z.object({ name: z.string(), role: z.string().optional() }),
});
export const CtaBlock = z.object({
  _type: z.literal("cta"),
  _key: z.string(),
  label: z.string(),
  href: z.string(),
});

export const Block = z.discriminatedUnion("_type", [HeroBlock, QuoteBlock, CtaBlock]);
export type Block = z.infer<typeof Block>;
export type BlockType = Block["_type"];

// Strapi-style "blocks.hero" and Contentful-style "ComponentHero" both become "hero".
export function normalizeType(raw: Record<string, unknown>): Record<string, unknown> {
  const t = String(raw._type ?? raw.__component ?? raw.__typename ?? raw.component ?? "");
  const name = t.replace(/^blocks\./, "").replace(/^Component/, "");
  return { ...raw, _type: name.charAt(0).toLowerCase() + name.slice(1) };
}

The renderer uses a mapped type so that every member of the union must have a component. Adding GalleryBlock to the union without adding a gallery entry to the registry is a compile error.

TypeScript
// components/blocks/registry.tsx
import type { Block, BlockType } from "@/lib/blocks/schema";
import { Hero } from "./hero";
import { Quote } from "./quote";
import { Cta } from "./cta";

type Registry = { [K in BlockType]: (props: Extract<Block, { _type: K }>) => JSX.Element };

export const registry: Registry = { hero: Hero, quote: Quote, cta: Cta };

export function BlockView({ block }: { block: Block }) {
  const Component = registry[block._type] as (props: Block) => JSX.Element;
  return <Component {...block} />;
}

At the fetch boundary, parse each block separately. Unknown types and invalid blocks are dropped with a log line, which keeps a page rendering when editors use a block that the deployed frontend does not support yet.

TypeScript
// lib/blocks/parse.ts
import { Block } from "./schema";
import { normalizeType } from "./schema";

export function parseBlocks(raw: unknown[], context: { entryId: string }): Block[] {
  const out: Block[] = [];
  for (const item of raw) {
    const result = Block.safeParse(normalizeType(item as Record<string, unknown>));
    if (result.success) out.push(result.data);
    else console.warn(JSON.stringify({ kind: "block_dropped", entry: context.entryId, issue: result.error.issues[0]?.message }));
  }
  return out;
}

Nested blocks

Some blocks contain other blocks, such as a two-column layout whose columns hold their own block arrays. Model them with the same union, using a lazy reference in the schema, and cap the nesting depth in the CMS so a layout block cannot contain another layout block. One level of containers covers real layouts; deeper nesting makes editing confusing and queries expensive, and it is usually a sign that a design pattern should become its own block type.

Configuration Reference

Concern Recommendation Why
Discriminator name one field, _type, after normalization Components and tests never deal with platform names.
Discriminator values lowerCamelCase, stable forever Renaming a value is a breaking change for stored content.
Item key CMS per-item key, required Stable React keys and click-to-edit mapping.
Allowed blocks restricted per field in the CMS Editors only see blocks the layout supports.
Nesting at most one container level Keeps editing and queries simple.
Unknown blocks dropped with a warning New blocks can ship to the CMS before the frontend.

Gotchas & Edge Cases

  • Renaming a block type. The discriminator is stored in every piece of content that uses the block. Renaming it in the model does not rename existing data on most platforms, so the frontend must accept both names until a migration rewrites the content.
  • Shared field names with different shapes. A discriminated union handles this correctly, which is the point, but only if nothing downstream reads fields from the base type. Avoid helper functions that accept “any block” and read image.
  • GraphQL fragments. When querying a union in GraphQL, each member needs its own inline fragment. Generate the fragments from the same list of block types, or a new block will be returned with only __typename.
  • Preview of new blocks. Editors previewing a block the frontend does not know yet see nothing where it should be. Render a visible placeholder in draft mode instead of dropping silently, so they understand why.

Worked Example

The marketing site replaced its loose section interface with a union of fourteen block schemas, normalized the discriminator at the fetch boundary and introduced the typed registry. The compiler immediately found six components reading fields that did not exist on the blocks they rendered, including the testimonial image bug. Two blocks turned out to be unused and were removed from the model. Over the following quarter, the site shipped four new block types; each one was a schema, a component and a registry entry, and none reached production without both halves in place.

Defects found when the union was introducedType errors reported by the compiler when the loose section interface was replaced by a discriminated union, grouped by kind.Field read from wrong block6 type errorsMissing null handling11 type errorsWrong field shape3 type errorsUnused block types found2 type errors
Every one of these compiled cleanly under the old interface.

Rollout Checklist

  • List every block type in the model with its discriminator value and fields.
  • Write one schema per block and combine them into a discriminated union.
  • Normalize the platform’s discriminator to _type at the fetch boundary.
  • Replace switch statements and conditional renders with a typed registry.
  • Parse blocks individually and log dropped blocks with the entry id.
  • Show a placeholder for unknown blocks in draft mode.

Frequently Asked Questions

Can I generate the union from the CMS schema instead of writing it?

Yes. Sanity TypeGen, GraphQL Code Generator and several platform-specific tools emit block types from the model. Keep a thin runtime schema for validation, or generate that too where the tooling supports it.

Is a discriminated union slower to validate than a plain union?

It is faster. The validator reads the discriminator and checks one schema, instead of trying every member until one matches, and its error messages refer to the right block type.

How should I test the registry?

Keep one fixture per block type, including empty and maximum-length variants, and render each through the registry in a component test. A test that iterates over every discriminator value catches missing entries even if a cast hides them from the compiler.

What if two sites share blocks but not all of them?

Keep one union and one registry for the shared library, and restrict allowed blocks per site in the CMS. Site-specific blocks can extend the union in that site’s code.