Skill 05 · Sanity Best Practices
Subchapter 5.15
references/page-builder.mdMarkdown8 KBView on GitHub
This guide covers Page Builder patterns—arrays of block objects that allow content teams to compose flexible page layouts. For Portable Text (rich text within documents), see portable-text.md.
A page builder is an array of objects (pageBuilder[]) that allows content teams to compose pages from reusable blocks without developer intervention.
When to use:
When NOT to use:
schemaTypes/
├── blocks/ # Page builder blocks (objects)
│ ├── heroType.ts
│ ├── featuresType.ts
│ └── faqsType.ts
├── pageBuilderType.ts # The array definition
└── pageType.ts # Document using the page builder| Use Objects | Use References |
|---|---|
| Content is unique to this page | Content reused across many pages |
| Simpler queries | Needs central management |
| Default choice | FAQs, CTAs, testimonials |
Rule: Use references sparingly. Most blocks should be objects.
// pageBuilderType.ts
import { defineType, defineArrayMember } from "sanity";
export const pageBuilderType = defineType({
name: "pageBuilder",
type: "array",
of: [
defineArrayMember({ type: "hero" }),
defineArrayMember({ type: "splitImage" }),
defineArrayMember({ type: "features" }),
defineArrayMember({ type: "faqs" }),
],
options: {
insertMenu: {
views: [
// Optional: Show visual thumbnails in the insert menu grid
{ name: "grid", previewImageUrl: (type) => `/block-previews/${type}.png` },
],
},
},
});Every block should have consistent previews:
import { defineType } from "sanity";
import { BlockContentIcon } from "@sanity/icons/BlockContent";
export const splitImageType = defineType({
name: "splitImage",
type: "object",
icon: BlockContentIcon,
fields: [/* ... */],
preview: {
select: { title: "title", media: "image" },
prepare({ title, media }) {
return {
title: title || "Untitled",
subtitle: "Split Image", // Block type name
media: media ?? BlockContentIcon, // Fallback to icon
};
},
},
});Expand references only for blocks that need them:
*[_type == "page" && slug.current == $slug][0]{
...,
content[]{
...,
_type == "faqs" => {
...,
faqs[]-> // Expand only FAQ references
}
}
}Use Extract to type individual blocks from the query result:
import { PAGE_QUERYResult } from "@/sanity/types";
type HeroProps = Extract<
NonNullable<NonNullable<PAGE_QUERYResult>["content"]>[number],
{ _type: "hero" }
>;
export function Hero({ title, image }: HeroProps) {
// Fully typed!
}export function PageBuilder({ content }: { content: Block[] }) {
if (!Array.isArray(content)) return null;
return (
<main>
{content.map((block) => {
switch (block._type) {
case "hero":
return <Hero key={block._key} {...block} />;
case "features":
return <Features key={block._key} {...block} />;
case "splitImage":
return <SplitImage key={block._key} {...block} />;
default:
return <div key={block._key}>Unknown: {block._type}</div>;
}
})}
</main>
);
}Always use _key for React keys:
// Breaks Visual Editing and causes hydration issues
{items.map((item, i) => <Component key={i} {...item} />)}
// Always use Sanity's _key
{items.map((item) => <Component key={item._key} {...item} />)}Use stegaClean when block fields control rendering logic:
import { stegaClean } from "next-sanity";
function SplitImage({ orientation, title, image }) {
return (
<section data-orientation={stegaClean(orientation) || "imageLeft"}>
{/* ... */}
</section>
);
}For faster live updates in the Presentation Tool, use presentation queries that fetch only the specific block being edited, rather than re-fetching the entire page.
Note: This pattern uses
usePresentationQueryfromnext-sanity/hooks. For other frameworks, check your loader package for equivalent functionality.
// queries.ts
export const HERO_PRESENTATION_QUERY = defineQuery(`
*[_id == $documentId][0]{
_id,
_type,
"heroBlock": pageBuilder[_key == $blockKey && _type == "hero"][0]{
title,
subtitle,
image,
// ... all fields the component needs
}
}
`)usePresentationQuery in your component:'use client'
import { usePresentationQuery } from 'next-sanity/hooks'
import { HERO_PRESENTATION_QUERY } from '@/sanity/lib/queries'
type HeroProps = {
_key: string
documentId: string
// ... initial props from page query
}
export function Hero({ _key, documentId, ...initialProps }: HeroProps) {
// Fetch block-specific data for faster updates
const { data } = usePresentationQuery({
query: HERO_PRESENTATION_QUERY,
params: { documentId, blockKey: _key },
})
// Use presentation data if available, fallback to initial props
const blockData = data?.heroBlock || initialProps
return (
<section>
<h1>{blockData.title}</h1>
{/* ... */}
</section>
)
}This pattern is especially valuable for pages with many blocks or complex nested data.
Note: See nextjs.md for more details on usePresentationQuery and visual-editing.md for the conceptual overview.
| Pitfall | Solution |
|---|---|
| Too many block variations | Split into separate blocks if >2 variants |
| Paradox of choice | Limit blocks per document type |
| Overusing references | Default to objects; references only for truly shared content |
| Unused blocks accumulate | Prune regularly; see deprecation patterns |
| Inconsistent previews | Always set title, subtitle (block name), and media/icon |
Map Sanity “alignment” fields (usually string/select) to CSS classes using utility functions.
Schema:
defineField({
name: 'align',
type: 'string',
options: { list: ['left', 'center', 'right'], layout: 'radio' }
})Implementation (Utility):
import { stegaClean } from "@sanity/client/stega";
export function getTextAlign(align?: string) {
// CLEAN the value before switching!
switch (stegaClean(align)) {
case 'left': return 'text-left';
case 'right': return 'text-right';
default: return 'text-center';
}
}Rule: Do NOT store heading levels (h1, h2) in Sanity schema options. Determine them dynamically in the frontend to ensure accessibility.
Bad Schema:
// Don't do this
{ name: 'level', type: 'string', options: { list: ['h1', 'h2'] } }Good Component:
Pass a semanticLevel prop based on the component’s context/nesting.
type Props = {
block: HeroBlock;
level?: 'h1' | 'h2' | 'h3'; // Default to h2 if undefined
}
export default function Section({ block, level = 'h2' }: Props) {
const Tag = level;
return <Tag>{block.title}</Tag>;
}Note: For Image patterns, see image.md. For Portable Text patterns, see portable-text.md.