Stop Rebuilding the Same Landing Page
Every agency has a version of this story. A client signs off on a beautiful homepage. Three weeks later they want the testimonials moved above the pricing table, a new FAQ section, and the hero swapped for one with a background video. None of that is hard. All of it is a developer ticket, a branch, a review, and a deploy.
Multiply that across a dozen clients and you are not running a studio anymore — you are running a queue.
The fix is not "use a CMS." Most teams already do. The fix is deciding what unit of content the CMS actually owns. If it owns fields on a fixed template, you have moved the copy out of the codebase and left the layout in it. Editors still can't build a page. If it owns an ordered list of blocks, editors can.
What a block-based page actually is
A page stops being a template and becomes an array:
'custom.features-grid': {
element: defineAsyncComponent(() => import('./CustomFeaturesGrid.vue')),
props: { ...data },
},Each entry is a typed object with its own schema. The frontend reads the array in order and renders a component per entry. Reorder the array in the Studio, and the page reorders. Add a block, and the page grows. Delete one, and it's gone. No deploy, no branch, no ticket.
In this starter that dispatch is a single renderer. ComponentRenderer.vue looks up the Sanity _type in a registry and mounts the matching Vue component:
npm run scaffold:component -- custom.testimonialAsync imports matter here. A page that uses four blocks ships four blocks' worth of JavaScript, not the whole library.
The three-file contract
The temptation with a page builder is to make block creation "easy" by making it dynamic — generic field bags, JSON blobs, a schema-less escape hatch. That buys you a fast first block and an unmaintainable twentieth.
The alternative is a small, boring contract. Every block is exactly three things:
- A Sanity schema —
studio/schemas/web/custom/custom.hero.ts - A Vue component —
app/components/custom/CustomHero.vue - A registration — one line in
customComponentImports()
The naming maps straight across: custom.hero → CustomHero.vue → custom.hero.ts. When you have thirty blocks, you can find any of them without grep.
It's repetitive enough that we scaffolded it:
npm run scaffold:component -- custom.testimonial-wallThat writes all three files and wires the registrations. Roughly a minute from idea to a block an editor can insert.
Type safety is what makes this survivable
Here is the failure mode that kills page builders in year two: someone renames a schema, or deletes a block type, and nothing complains. The Studio quietly starts rendering blank sections. You find out when a client emails.
Sanity's TypeGen closes that hole. Schemas compile to TypeScript types:
cd studio && npm run schema:typesComponents consume them directly:
<script setup lang="ts">
import type { HeroSimple, ComponentProps } from '~~/types'
interface Props extends ComponentProps<HeroSimple> {}
const props = defineProps<Props>()
</script>And the registry itself is checked against the union of every valid block type with satisfies. Rename a schema and forget to update the registry, and typecheck fails in CI. Not in production, not in a client email — in CI.
That single constraint is what lets a page builder grow past ten blocks without becoming folklore.
Give editors control, not a blank canvas
The other way page builders die is by giving away too much. Once editors can set arbitrary padding, colours, and widths per block, every page drifts and the design system stops meaning anything.
The useful boundary is: editors control composition and content; the system controls layout primitives.
Horizontal width is a good example. In this starter, individual blocks never set their own outer gutter or max-width. That belongs to PageSection, which exposes a three-state intent to the editor:
contained— the default 88rem container with a responsive gutterfull— full-width band, content still insetbleed— edge-to-edge, for media, carousels, and maps
Three named choices, not a padding input. Editors get real layout control; the page still can't end up with a hero inset by 4px more than the section under it.
Colour works the same way. Themes are documents in Sanity, compiled to CSS variables at build time:
npm run cssvars:generateNo hex codes in Vue files. A client rebrand is content editing, not a refactor.
What actually changes
The measurable difference isn't rendering speed — it's who is unblocked.
- A new landing page for a campaign: editor, that afternoon, no deploy.
- Reordering sections after a stakeholder review: editor, two minutes.
- A genuinely new block type: developer, one scaffold command, one component.
Developers stop being a bottleneck for layout changes and go back to building the things only developers can build. That's the whole return.
Next up: a walkthrough of getting the whole stack — Nuxt, Studio, demo content, and theming — running locally in about ten minutes.
