{
  "$type": "site.standard.document",
  "bskyPostRef": {
    "cid": "bafyreihlfj4lmn2ny3glz6b2gnxt3day434u7elurvxkzsrny3cdlrey2a",
    "uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mpgtvsezttt2"
  },
  "coverImage": {
    "$type": "blob",
    "ref": {
      "$link": "bafkreideaabvmtk4hofb2f3x3acbwq5lb466ldhsfhf3lbli75qkdaxusm"
    },
    "mimeType": "image/webp",
    "size": 56532
  },
  "path": "/maciejtrzcinski/every-sanity-page-builder-has-the-same-bug-k1c",
  "publishedAt": "2026-06-29T15:47:38.000Z",
  "site": "https://dev.to",
  "tags": [
    "sanity",
    "typescript",
    "nextjs",
    "webdev",
    "@maciejtrzcinski/sanity-page-builder-core",
    "@maciejtrzcinski/sanity-plugin-section-builder",
    "github.com/maciejtrzcinski/sanity-page-builder-example"
  ],
  "textContent": "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.\n\nAnd every one of them ships with the same bug.\n\nYou 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.\n\nThe 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.\n\n##  The section tax\n\nHere's what \"add a section\" actually costs in a typical Sanity + Next.js page builder:\n\n  1. **Schema** — a new `*Section` object type, registered in your schema index.\n  2. **GROQ** — a new conditional in the page-builder projection so the block's fields actually come down.\n  3. **Component** — the React component that renders it.\n  4. **Renderer map** — an entry mapping `_type` → component.\n  5. **Types** — the block variant in whatever union your frontend renders.\n\n\n\nMiss #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.\"\n\nNow look at those five places and ask: **which of them is actually unique to your site?**\n\nThe component is. It's welded to your design system — your spacing, your tokens, your brand. Nobody can reuse it and nobody should.\n\nThe 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?\n\n##  Extract the plumbing, keep the sections\n\nThe 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).\n\nBut forget the packages for a second — the _pattern_ is the point. They're just the convenient version of it.\n\n###  One source of truth per section\n\nThe 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:\n\n\n\n    const defineSection = createSectionFactory<PageBuilderBlock, RenderContext, ReactNode>()\n\n    const hero = defineSection({\n      type: 'heroSection',\n      query: `title, subtitle, ctaHref`,\n      render: (block) => <Hero {...block} />,\n    })\n\n\nType, query, renderer — in one object. Now you can't add a renderer and forget the projection, because they're the same declaration.\n\n###  Build the render loop _and_ the GROQ from the same list\n\n\n    const pb = createPageBuilderFromSections([hero, /* … */], { strict: true })\n\n    pb.renderBlock   // dispatch a block to its renderer\n    pb.query         // `_type == \"heroSection\" => { title, subtitle, ctaHref }, …`\n\n\nThe 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.\n\n`strict: true` means a block whose `_type` has no renderer throws instead of silently skipping. Forgetting to register a section becomes loud.\n\n###  Make the compiler enforce parity\n\nThe 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.\n\nAnd 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:\n\n\n\n    assertPageBuilderIntegrity({ renderers, registeredTypes, query: pageBuilderFields })\n\n\nIt 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.\n\nThe 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.\n\n##  The other half: a Studio that doesn't suck to use\n\nThe 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.\n\nThe plugin attaches previews globally and auto-detects your sections:\n\n\n\n    // sanity.config.ts\n    plugins: [sectionBuilder(SECTIONS)]\n\n    // your page document\n    fields: [sectionField(SECTIONS, { group: 'content' })]\n\n\nDrop 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.\n\n##  The shape, end to end\n\nTwo sources of truth — the section list on each side — and the packages do the wiring between them:\n\n\n\n    Studio\n      sectionBuilder(SECTIONS)   → thumbnails everywhere\n      sectionField(SECTIONS)     → the page-builder array + insert grid\n\n    Frontend\n      defineSection({ type, query, render }) × N\n      createPageBuilderFromSections(...)  → renderBlock + combined GROQ\n\n\nI 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.\n\n→ **github.com/maciejtrzcinski/sanity-page-builder-example**\n\n##  The point\n\nYou'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.\n\nKeep 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.\n\nIf you try it, tell me where it breaks — issues and PRs welcome, and a star helps other people find it.",
  "title": "Every Sanity page builder has the same bug"
}