Most portfolio and marketing sites start the same way: one file per page, content hard-coded, and a developer in the loop for every new section. It works until the second time someone asks "can we add a page for…". This is how I moved a site to a typed page builder, so that a new page is a document in the CMS, not a pull request.
The idea in one paragraph
A page is a document with a slug and an ordered list of blocks (hero, intro, stats, services, projects, testimonials, contact form…). Each block type has a schema in Sanity and exactly one component on the site. A single route renders any page by walking its blocks. Editors compose pages from blocks; developers add a new kind of block when the design needs one.
The schema: a page is a slug plus blocks
// pageType.ts
export const pageType = defineType({
name: 'page',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string', validation: (r) => r.required() }),
defineField({ name: 'slug', type: 'slug', options: { source: 'title' } }),
defineField({ name: 'content', type: 'pageBuilder' }),
defineField({ name: 'seo', type: 'seo' }),
],
})
// pageBuilderType.ts: the blocks a page can use
export const pageBuilderType = defineType({
name: 'pageBuilder',
type: 'array',
of: [
defineArrayMember({ type: 'hero' }),
defineArrayMember({ type: 'pageIntro' }),
defineArrayMember({ type: 'services' }),
defineArrayMember({ type: 'testimonials' }),
// …one entry per block type
],
})Each block is a small object type with only the fields its design needs. Keep them narrow: a "Services" block with three fields beats a generic "Section" block with twenty optional ones that editors have to guess at.
The query: one projection per block
GROQ lets each block ask for exactly what its component renders, including following references (testimonials, projects) in the same query:
*[_type == "page" && slug.current == $slug][0]{
title, "slug": slug.current, seo,
content[]{
_key, _type,
_type == "hero" => { title, tagline, intro, links[]{ label, href } },
_type == "services" => { heading, services[]{ title, description, icon } },
_type == "testimonials" => {
heading,
"testimonials": testimonials[]->{ name, review, "image": image{ asset, alt } }
}
}
}One round trip per page, and no over-fetching.
The renderer: a typed block map
On the site, one component maps block types to components. The useful trick is making TypeScript check that every block type has a component and every component receives its own block's shape:
// PageBuilder.astro (frontmatter)
const blocks: {
[Type in PageBuilderBlock['_type']]: (props: Extract<PageBuilderBlock, { _type: Type }>) => unknown;
} = {
hero: Hero,
pageIntro: PageIntro,
services: Services,
testimonials: Testimonials,
};PageBuilderBlock is a union of the block interfaces, discriminated by _type. Add a block to the union and forget the component, and the build fails, which is exactly when you want to find out.
Spacing between blocks lives in the renderer, not in the blocks: the builder inserts a divider between consecutive blocks, so blocks never set their own top margin and any order looks intentional.
One route for every page
---
// src/pages/[slug].astro
export async function getStaticPaths() {
const slugs = await client.fetch(`*[_type == "page" && defined(slug.current)].slug.current`);
return slugs.map((slug) => ({ params: { slug } }));
}
const page = await client.fetch(PAGE_QUERY, { slug: Astro.params.slug });
---
<Layout {...seoFrom(page)}>
<PageBuilder content={page.content} />
</Layout>The home page is the same page type, pinned as a singleton. Creating "About", "Work" or "Notes" is now: new document, pick blocks, publish.
The parts that make it pleasant
- Navigation is content too. Header and footer links live in site settings and can reference pages, so renaming a slug never breaks a link.
- Live preview. Sanity's Presentation tool shows the real site next to the editor; with stega encoding, clicking any text jumps to its field. Strip the encoding (
stegaClean) from anything used as an attribute (URLs, class names), or invisible characters end up in your HTML. - SEO per page. A small SEO object (title, description, page type) on every page, with defaults from site settings. Share images can be generated from the title, so there's nothing to upload.
- Collections stay collections. Projects, testimonials and notes are their own document types, listed by blocks ("All projects", "Note list"). A page builder composes pages; it shouldn't replace real content types.
What not to do
- Don't let editors define arbitrary layouts. Columns-inside-columns builders produce pages nobody can maintain. A fixed set of designed blocks keeps the site coherent.
- Don't make blocks fetch their own data. One query per page keeps it fast and lets the static build know everything up front.
- Don't skip types. The typed block map turns "I added a block but the page is blank" into a compile error.
The result: new pages without a deploy of code, a design that holds together because every block was designed, and TypeScript guarding the seams between the CMS and the site.