Files
johannes.gasser 57af0b8386
Build and Deploy / build-and-deploy (push) Successful in 2m53s
add skills
2026-05-18 08:39:42 +02:00

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 usePresentationQuery from next-sanity/hooks. For other frameworks, check your loader package for equivalent functionality.

The Pattern

  1. 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
    }
  }
`)
  1. Use 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>
  )
}

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.