A page builder is deceptively simple to start and brutal to scale. The first ten block types are a switch statement. By block fifty, that switch is a 2,000-line file nobody wants to touch, every block knows about every other block, and adding a new one means editing six places and praying. Here is the architecture that kept a 150-block builder maintainable.
Model blocks as a discriminated union
The foundation is the data model. Every block is one member of a discriminated union keyed by a type field. This gives you exhaustive type-checking for free: add a block to the union and TypeScript tells you every place that now needs to handle it.
type BaseBlock = {
id: string;
type: string;
};
type HeroBlock = BaseBlock & {
type: "hero";
props: { heading: string; subheading?: string; cta?: CtaProps };
};
type GalleryBlock = BaseBlock & {
type: "gallery";
props: { images: ImageRef[]; columns: 2 | 3 | 4 };
};
type RichTextBlock = BaseBlock & {
type: "richText";
props: { html: string };
};
// The union — the single source of truth for what a page can contain.
export type Block = HeroBlock | GalleryBlock | RichTextBlock; // + 147 moreA registry, not a switch statement
The mistake that kills page builders is a central switch (block.type) that renders every block. It becomes a merge-conflict magnet and a bundle anchor. Instead, each block registers itself: its React component, its editor controls, its default props, and a Zod schema for validation. The registry is the only thing the engine knows about.
type BlockDefinition<T extends Block = Block> = {
type: T["type"];
component: React.ComponentType<{ block: T }>;
schema: z.ZodType<T["props"]>;
defaults: () => T["props"];
label: string;
icon: React.ComponentType;
};
const registry = new Map<string, BlockDefinition>();
export function registerBlock<T extends Block>(def: BlockDefinition<T>) {
registry.set(def.type, def as BlockDefinition);
}
export function getBlock(type: string) {
const def = registry.get(type);
if (!def) throw new Error(`Unknown block type: ${type}`);
return def;
}Each block definition lives in its own folder with its component, schema and editor controls colocated. Adding a block is: create the folder, call registerBlock. No central file to edit, so no merge conflicts between teams shipping blocks in parallel.
Render through the registry
The render path collapses to a single lookup. Note there is no block-specific logic here at all — the engine is completely closed to modification and open to extension, which is the whole point.
export function PageRenderer({ blocks }: { blocks: Block[] }) {
return (
<>
{blocks.map((block) => {
const def = getBlock(block.type);
const Component = def.component;
return <Component key={block.id} block={block} />;
})}
</>
);
}Validate at the boundary
A page builder persists arbitrary JSON and loads it back later — across schema changes, from AI generation, from imports. Never trust that blob. Validate every block against its registered schema at the load boundary and drop or repair anything invalid, so one malformed block can never crash the whole page.
export function parsePage(raw: unknown): Block[] {
const asArray = Array.isArray(raw) ? raw : [];
return asArray.flatMap((candidate) => {
if (typeof candidate?.type !== "string") return [];
const def = registry.get(candidate.type);
if (!def) return []; // unknown block type — skip, don't throw
const result = def.schema.safeParse(candidate.props);
if (!result.success) return []; // malformed props — skip
return [{ id: candidate.id, type: candidate.type, props: result.data }];
});
}This boundary is also your security line when an LLM generates pages. If the model hallucinates a block type or invents props, safeParse rejects it — the user sees a valid page minus the bad block, never a white screen.
Keep it fast as the catalog grows
- Lazy-load block components through the registry with next/dynamic so the editor only ships the blocks actually on the page.
- Memoise block components on their props; a drag that reorders blocks must not re-render every block's subtree.
- Give every block a stable id (not its array index) as the React key, or drag-reordering thrashes the DOM.
The architecture's real test is the 151st block. With a registry and a discriminated union, adding it is a self-contained folder and one registration call — the engine, the renderer and the validator never change. That is the difference between a builder that scales and one that becomes the thing everyone is afraid to touch.