8.1 KiB
title, description
| title | description |
|---|---|
| Sanity Page Builder Patterns | Patterns for Sanity Page Builder arrays, block components, and live editing. |
Sanity Page Builder Patterns
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.
1. What is a Page Builder?
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:
- Flexible layouts needed (marketing pages, landing pages)
- Content can be reordered
- Different components on different pages
When NOT to use:
- Rigid, formulaic content (blog posts, product pages)
- Highly structured data that doesn't change layout
- Rich text within a document body—use Portable Text instead
2. Schema Organization
Directory Structure
schemaTypes/
├── blocks/ # Page builder blocks (objects)
│ ├── heroType.ts
│ ├── featuresType.ts
│ └── faqsType.ts
├── pageBuilderType.ts # The array definition
└── pageType.ts # Document using the page builder
Objects vs References
| 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.
Page Builder Array
// 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` },
],
},
},
});
Block Preview Pattern
Every block should have consistent previews:
import { defineType } from "sanity";
import { BlockContentIcon } from "@sanity/icons";
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
};
},
},
});
3. Querying Page Builders
Expand references only for blocks that need them:
*[_type == "page" && slug.current == $slug][0]{
...,
content[]{
...,
_type == "faqs" => {
...,
faqs[]-> // Expand only FAQ references
}
}
}
4. Rendering Page Builders
TypeScript Typing
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!
}
Switch-Based Rendering
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} />)}
Cleaning Values for Logic
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>
);
}
5. Presentation Queries for Live Editing (Next.js)
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.
The Pattern
- Create a block-specific presentation query:
// 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
}
}
`)
- Use
usePresentationQueryin 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>
)
}
Why This Is Faster
- Without: Editing a field triggers a full page re-render with all blocks
- With: Only the specific block re-renders with its targeted query
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.
6. Page Builder Pitfalls
| 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 |
7. Component Alignment Pattern
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';
}
}
8. Semantic Heading Levels
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.