DEV Community

Every Sanity page builder has the same bug

Every Sanity marketing site ends up with a page builder. An array of sections, an insert menu, a render loop that maps block._type to a component. You've built it. I've built it. We've all built the same thing.

And every one of them ships with the same bug.

You add a new section. You wire it into the schema. You add a renderer. You add a component. You add the type. And then — because there are five places to touch and you're a human — you forget one. The section renders blank in production. Or it never shows up in the insert menu. Or it fetches no fields because you missed the GROQ projection, so it renders as nothing at all. No error. No red. Just a hole on the page where a section should be.

The annoying part isn't the bug. It's that you'll hit it again on the next project, in exactly the same way, because you rewrote the whole thing from scratch — again.

The section tax

Here's what "add a section" actually costs in a typical Sanity + Next.js page builder:

  1. Schema — a new *Section object type, registered in your schema index.
  2. GROQ — a new conditional in the page-builder projection so the block's fields actually come down.
  3. Component — the React component that renders it.
  4. Renderer map — an entry mapping _type → component.
  5. Types — the block variant in whatever union your frontend renders.

Miss #2 and the block arrives empty. Miss #4 and it silently skips. Miss #5 and TypeScript shrugs because your union is hand-maintained and now lies. Three different failure modes, all of them quiet, all of them "works on my machine until it doesn't."

Now look at those five places and ask: which of them is actually unique to your site?

The component is. It's welded to your design system — your spacing, your tokens, your brand. Nobody can reuse it and nobody should.

The other four are plumbing. "Look up _type in a map, call the renderer, keep the map in sync with the schema and the query." That code is byte-for-byte the same idea on every project you've ever built. So why is it living in your repo, hand-rolled, drifting, for the fifth time?

Extract the plumbing, keep the sections

The split that works: your sections stay yours, and the generic dispatcher becomes a dependency you don't think about. That's the whole bet behind the two packages I pulled out of doing this one too many times — @maciejtrzcinski/sanity-page-builder-core (frontend) and @maciejtrzcinski/sanity-plugin-section-builder (Studio).

But forget the packages for a second — the pattern is the point. They're just the convenient version of it.

One source of truth per section

The drift happens because a section's type, its GROQ query, and its render live in three different files. Co-locate them and they can't drift:

const defineSection = createSectionFactory<PageBuilderBlock, RenderContext, ReactNode>()

const hero = defineSection({
  type: 'heroSection',
  query: `title, subtitle, ctaHref`,
  render: (block) => <Hero {...block} />,
})
Enter fullscreen mode Exit fullscreen mode

Type, query, renderer — in one object. Now you can't add a renderer and forget the projection, because they're the same declaration.

Build the render loop and the GROQ from the same list

const pb = createPageBuilderFromSections([hero, /* … */], { strict: true })

pb.renderBlock   // dispatch a block to its renderer
pb.query         // `_type == "heroSection" => { title, subtitle, ctaHref }, …`
Enter fullscreen mode Exit fullscreen mode

The combined GROQ projection is derived from your sections, not typed out by hand next to them. The thing that used to silently fall out of sync is now generated from the same source as the renderers. That's the entire class of "section renders blank because I forgot the query" — gone.

strict: true means a block whose _type has no renderer throws instead of silently skipping. Forgetting to register a section becomes loud.

Make the compiler enforce parity

The dispatcher is generic over your block union, so the renderer map is a mapped type over block._type. The compiler enforces exactly one correctly-typed renderer per variant — each renderer receives the narrowed block, no casts. Add a section to the union, forget its renderer, and the build goes red before you ever ship the blank.

And for the parts a type system can't see (the registered schema types, the runtime GROQ string), there's one assertion you drop in a test:

assertPageBuilderIntegrity({ renderers, registeredTypes, query: pageBuilderFields })
Enter fullscreen mode Exit fullscreen mode

It fails loudly the moment your renderer map, your registered sections, and your projection drift apart. The five-places bug becomes a failing test in CI instead of a hole on a live page.

The core is dependency-free, by the way — the node type is a generic parameter, so it doesn't drag React or Sanity in. You can read the whole thing in one sitting. That's deliberate: it's plumbing, it should be boring and small.

The other half: a Studio that doesn't suck to use

The render loop is the frontend. The other recurring tax is the Studio side — the insert menu that's just a list of type names, and the components: { preview: … } block you copy-paste into every single section schema so editors get a thumbnail.

The plugin attaches previews globally and auto-detects your sections:

// sanity.config.ts
plugins: [sectionBuilder(SECTIONS)]

// your page document
fields: [sectionField(SECTIONS, { group: 'content' })]
Enter fullscreen mode Exit fullscreen mode

Drop in a *Section type and it's picked up automatically. Previews resolve by filename convention — name a section heroSection, drop hero-section.png in your previews folder, and it shows up in the array editor, the edit pane, and the native insert-menu grid. No per-schema wiring, no hand-maintained map. Your editors get a visual grid; you delete code.

The shape, end to end

Two sources of truth — the section list on each side — and the packages do the wiring between them:

Studio
  sectionBuilder(SECTIONS)   → thumbnails everywhere
  sectionField(SECTIONS)     → the page-builder array + insert grid

Frontend
  defineSection({ type, query, render }) × N
  createPageBuilderFromSections(...)  → renderBlock + combined GROQ
Enter fullscreen mode Exit fullscreen mode

I put a full working version up so you don't have to take my word for it: a minimal Next.js + Sanity app, five sections (Hero, Feature Cards, Quote, FAQ, CTA Banner), a /[...slug] catch-all route, plus the stuff every real marketing site needs anyway — CMS-driven SEO metadata, Open Graph, JSON-LD, sitemap/robots, ISR + Live caching, and a revalidation webhook. Clone it, pnpm seed, and you've got a page builder running in a few minutes.

github.com/maciejtrzcinski/sanity-page-builder-example

The point

You're going to build a page builder on your next Sanity marketing site. You already know how — that's the problem. Knowing how means rewriting the same drift-prone plumbing for the fifth time and re-discovering the same silent bug.

Keep your sections. They're the part that's actually yours. Let the boring dispatcher be a dependency that fails loudly when you forget a wire, instead of a hand-rolled thing that fails quietly in front of a client.

If you try it, tell me where it breaks — issues and PRs welcome, and a star helps other people find it.

Top comments (0)