Files
theater-ziefen-website/.agents/skills/sanity-best-practices/references/page-builder.md
T
johannes.gasser 57af0b8386
Build and Deploy / build-and-deploy (push) Successful in 2m53s
add skills
2026-05-18 08:39:42 +02:00

308 lines
8.1 KiB
Markdown

---
title: "Sanity Page Builder Patterns"
description: 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
```typescript
// 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:
```typescript
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:
```groq
*[_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:
```typescript
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
```typescript
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:**
```typescript
// 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:
```typescript
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 `usePresentationQuery` from `next-sanity/hooks`. For other frameworks, check your loader package for equivalent functionality.
### The Pattern
1. **Create a block-specific presentation query:**
```typescript
// 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
}
}
`)
```
2. **Use `usePresentationQuery` in your component:**
```typescript
'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:**
```typescript
defineField({
name: 'align',
type: 'string',
options: { list: ['left', 'center', 'right'], layout: 'radio' }
})
```
**Implementation (Utility):**
```typescript
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:**
```typescript
// 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.
```typescript
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`.*