This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
---
|
||||
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`.*
|
||||
Reference in New Issue
Block a user