Skip to content

Instantly share code, notes, and snippets.

@kmelve
Created January 31, 2026 18:31
Show Gist options
  • Select an option

  • Save kmelve/4d3cb29dced29fab5e0ceebabfc728ff to your computer and use it in GitHub Desktop.

Select an option

Save kmelve/4d3cb29dced29fab5e0ceebabfc728ff to your computer and use it in GitHub Desktop.
Reusable Page Blocks in Sanity - Implementation Guide

Reusable Page Blocks in Sanity - Implementation Guide

A pattern for creating once, using everywhere: how to implement reusable content blocks in Sanity that can be shared across multiple pages.


Overview

This pattern allows content editors to create a page block (hero, CTA, feature section, etc.) once as a standalone document, then reference it from multiple pages. Changes to the reusable block automatically propagate everywhere it's used.

Key benefits:

  • Single source of truth for shared content
  • Update once, reflect everywhere
  • Per-page overrides (e.g., color theme)
  • Content usage tracking in Studio

Architecture

┌─────────────────────────────────────────────────────────────────┐
│  Landing Page A                    Landing Page B               │
│  ┌──────────────────────┐         ┌──────────────────────────┐  │
│  │ content[]            │         │ content[]                │  │
│  │  ├─ heroPageBlock    │         │  ├─ featureSpotlight     │  │
│  │  ├─ reference ───────┼────┬────┼──┤ reference ────────────┤  │
│  │  └─ articlePageBlock │    │    │  └─ quotePageBlock       │  │
│  └──────────────────────┘    │    └──────────────────────────┘  │
└──────────────────────────────┼──────────────────────────────────┘
                               │
                               ▼
                    ┌────────────────────────┐
                    │  reusablePageBlock     │
                    │  (document)            │
                    │                        │
                    │  title: "Shared CTA"   │
                    │  invertColor: false    │
                    │  content[0]:           │
                    │    callToActionBlock   │
                    └────────────────────────┘

Part 1: Sanity Schema

1.1 The Reusable Block Document

Create a document type that wraps a single page block:

// schemas/documents/reusablePageBlock.ts
import { defineField, defineType } from "sanity"
import { BiSolidExtension } from "react-icons/bi"

export const reusablePageBlock = defineType({
  name: "reusablePageBlock",
  type: "document",
  icon: BiSolidExtension,
  fields: [
    defineField({
      name: "title",
      type: "string",
      description: "Internal name to identify this block in Studio",
      validation: (Rule) => Rule.required(),
    }),
    defineField({
      name: "content",
      type: "array",
      // Constrain to exactly ONE block
      validation: (Rule) => Rule.required().min(1).max(1),
      of: [
        { type: "heroPageBlock" },
        { type: "callToActionPageBlock" },
        { type: "featureSpotlightPageBlock" },
        // ... add all your page block types
      ],
    }),
    defineField({
      name: "invertColor",
      title: "Swap theme",
      type: "boolean",
      initialValue: false,
      description: "Switches theme opposite to page (e.g., dark on light page)",
    }),
  ],
  preview: {
    select: {
      title: "title",
      blockType: "content.0._type",
    },
    prepare({ title, blockType }) {
      const type = blockType
        ?.replace("PageBlock", "")
        .replace(/([A-Z])/g, " $1")
        .trim()

      return {
        title,
        subtitle: type ? `Reusable ${type}` : "Empty",
        media: BiSolidExtension,
      }
    },
  },
})

1.2 Add Reference to Page Content Arrays

In your page document (landing page, product page, etc.), add a reference option:

// schemas/documents/landingPage.ts
import { defineType } from "sanity"

export const landingPage = defineType({
  name: "landingPage",
  type: "document",
  fields: [
    // ... other fields
    {
      name: "content",
      title: "Page Blocks",
      type: "array",
      of: [
        // Inline block types
        { type: "heroPageBlock" },
        { type: "callToActionPageBlock" },
        { type: "featureSpotlightPageBlock" },
        // ... other block types

        // Reference to reusable blocks
        {
          type: "reference",
          to: [{ type: "reusablePageBlock" }],
          title: "Reusable Page Block",
        },
      ],
    },
  ],
})

Part 2: Frontend Implementation

2.1 Component Structure

Each page block component exports:

  1. The React component
  2. A .projection property (GROQ string for data fetching)
// components/page-blocks/CallToAction.tsx
import groq from "groq"

export namespace CallToActionBlock {
  export type Props = {
    _key: string
    _type: string
    eyebrow?: string
    title?: string
    description?: string
    invertColor?: boolean
    // ... other fields
  }
}

export function CallToActionBlock(props: CallToActionBlock.Props) {
  const { eyebrow, title, description, invertColor } = props

  return (
    <section data-inverted={invertColor}>
      {eyebrow && <span>{eyebrow}</span>}
      <h2>{title}</h2>
      <p>{description}</p>
    </section>
  )
}

// GROQ projection - defines which fields to fetch
CallToActionBlock.projection = groq`
  eyebrow,
  title,
  description,
  invertColor,
`

2.2 Component Registry

Create a registry mapping _type to components:

// components/page-blocks/index.tsx
import { FunctionComponent } from "react"

export interface PageBlockComponent extends FunctionComponent<any> {
  projection: string
}

// Registry of all available components
export const components: Record<string, PageBlockComponent> = {
  heroPageBlock: HeroBlock,
  callToActionPageBlock: CallToActionBlock,
  featureSpotlightPageBlock: FeatureSpotlightBlock,
  // ... all your block components
}

2.3 Dynamic Projection Builder

The key utility that handles both inline and referenced blocks:

// utils/create-projection.ts

// Base projection for all blocks
function createResolverProjection(
  components: Record<string, PageBlockComponent>
) {
  const baseProjection = `
    _type,
    _key,
    "_documentId": ^._id,
    "_documentType": ^._type,
  `

  // Build conditional projections per type
  const projectionParts = Object.entries(components).map(
    ([name, Component]) => `_type == "${name}" => { ${Component.projection} },`
  )

  return baseProjection + projectionParts.join("")
}

// Main projection builder - handles reusable blocks
export function createProjection(
  components: Record<string, PageBlockComponent>
) {
  let projection = createResolverProjection(components)

  // Handle references to reusablePageBlock
  // This dereferences the reference and extracts content[0]
  projection += `
    (_type == "reference" && @->_type == "reusablePageBlock") => @-> {
      ...content[0] { ${projection} },
      "invertColor": coalesce(invertColor, content[0].invertColor),
    },
  `

  return projection
}

Key GROQ patterns:

  • @-> dereferences the reference to get the full document
  • content[0] extracts the single block from the content array
  • coalesce(invertColor, content[0].invertColor) allows reusable block settings to override the inner block's settings
  • The spread ...content[0] { projection } merges the inner block's data

2.4 Page Renderer Component

// components/page-blocks/PageBlocks.tsx
import { Suspense } from "react"

export type PageBlock = {
  _type: string
  _key: string
  _documentId: string
  _documentType: string
}

export function PageBlocks(props: {
  blocks?: Array<PageBlock>
  components: Record<string, PageBlockComponent>
}) {
  return props.blocks?.map((block, i) => {
    // Skip if no matching component
    if (!(block._type in props.components)) return null

    const Component = props.components[block._type]

    return (
      <Suspense key={block._key} fallback={<div>Loading...</div>}>
        <Component
          {...block}
          _index={i}
          // Pass neighboring invertColor for adaptive styling
          _prevInvertColor={(props.blocks?.[i - 1] as any)?.invertColor}
          _nextInvertColor={(props.blocks?.[i + 1] as any)?.invertColor}
        />
      </Suspense>
    )
  })
}

2.5 Two-Query Optimization Pattern

For performance, use a two-query approach to only fetch projections for components actually used on the page:

// app/[slug]/page.tsx
import { defineQuery } from "next-sanity"
import groq from "groq"

// Query 1: Lightweight - just get types and reference IDs
const contentTypesQuery = defineQuery(`
  *[_type == "landingPage" && slug.current == $slug][0]{
    _id,
    "contentTypes": content[]._type,
    "referenceIds": content[_type == "reference"]._ref
  }
`)

// Query to get types from reusable blocks
function buildReusableBlockTypesQuery(referenceIds: string[]) {
  if (referenceIds.length === 0) return null
  return groq`
    *[_id in $referenceIds && _type == "reusablePageBlock"]{
      "contentType": content[0]._type
    }
  `
}

// Query 2: Full content with optimized projection
function buildContentQuery(usedComponents: Record<string, PageBlockComponent>) {
  return groq`
    *[_type == "landingPage" && slug.current == $slug][0]{
      _id,
      content[]{
        ${createProjection(usedComponents)}
      }
    }
  `
}

export async function LandingPage({ slug }: { slug: string }) {
  // Step 1: Get content types (lightweight query)
  const typesData = await sanityFetch({
    query: contentTypesQuery,
    params: { slug },
  })

  // Step 2: Get types from referenced reusable blocks
  const referenceIds = typesData?.referenceIds || []
  const reusableQuery = buildReusableBlockTypesQuery(referenceIds)

  const reusableData = reusableQuery
    ? await sanityFetch({ query: reusableQuery, params: { referenceIds } })
    : null

  // Step 3: Build filtered component registry
  const allTypes = new Set([
    ...(typesData?.contentTypes || []),
    ...(reusableData?.map(b => b.contentType) || []),
  ])

  const usedComponents = Object.fromEntries(
    Object.entries(components).filter(([name]) => allTypes.has(name))
  )

  // Step 4: Fetch full content with optimized projection
  const { data } = await sanityFetch({
    query: buildContentQuery(usedComponents),
    params: { slug },
  })

  return <PageBlocks blocks={data?.content} components={components} />
}

Part 3: Optional Enhancements

3.1 "Used On Pages" Field (Studio UX)

Show editors where a reusable block is used with real-time updates.

Uses react-rx (add as dependency - Studio uses it internally) with Sanity Studio primitives:

// components/UsedOnPagesInput.tsx
import { useMemo } from "react"
import { useObservable } from "react-rx"
import {
  type StringInputProps,
  useDocumentStore,
  useFormValue,
  type SanityDocument,
} from "sanity"
import { IntentLink } from "sanity/router"

interface UsagePage {
  _id: string
  title?: string
  _type: string
  slug?: { current: string }
}

const INITIAL_STATE: UsagePage[] = []

export function UsedOnPagesInput(props: StringInputProps) {
  const document = useFormValue([]) as SanityDocument | undefined
  const documentStore = useDocumentStore()

  // Extract clean ID (remove drafts. prefix)
  const blockId = document?._id?.replace(/^drafts\./, "") ?? ""

  // Create memoized observable for real-time updates
  const observable = useMemo(() => {
    if (!blockId) return null

    return documentStore.listenQuery(
      `*[references($blockId)]{ _id, title, _type, slug }`,
      { blockId },
      { apiVersion: "2024-01-01" }
    )
  }, [documentStore, blockId])

  // Subscribe to observable - auto-updates when data changes
  const usages = useObservable(observable, INITIAL_STATE)

  if (!blockId) return null
  if (!usages.length) return <div>Not used on any pages</div>

  return (
    <ul>
      {usages.map((page) => (
        <li key={page._id}>
          <IntentLink
            intent="edit"
            params={{ id: page._id, type: page._type }}
          >
            {page.title || "[Untitled]"}
          </IntentLink>
          {page.slug?.current && <span> /{page.slug.current}</span>}
        </li>
      ))}
    </ul>
  )
}

Why this pattern is better:

  • Real-time - Automatically updates when pages add/remove references to this block
  • No useEffect - useObservable handles subscription lifecycle
  • No manual cleanup - Unsubscribes automatically on unmount
  • React 19 compatible - Uses proper memoization patterns
  • Studio-native - Uses the same reactive patterns as Sanity Studio itself

Key imports:

  • useObservable from react-rx - Subscribe to observables in React
  • useDocumentStore from sanity - Access reactive document queries
  • useFormValue from sanity - Get current document data
  • IntentLink from sanity/router - Deep link to edit documents

Add dependency:

pnpm add react-rx

3.2 Filter Allowed Reusable Blocks Per Page Type

Restrict which block types can be used on specific page types:

// In customerStory.ts
{
  type: "reference",
  to: [{ type: "reusablePageBlock" }],
  options: {
    filter: 'content[0]._type in ["callToActionPageBlock", "quotePageBlock"]',
  },
}

3.3 Visual Editing Data Attributes

For Sanity Visual Editing support, include document context:

// In PageBlocks component
import { createDataAttribute } from "next-sanity"

<Component
  sanityDataAttribute={createDataAttribute({
    id: block._documentId,
    type: block._documentType,
    path: `content[_key=="${block._key}"]`,
  }).toString()}
  {...block}
/>

Summary

Layer File Purpose
Schema reusablePageBlock.ts Document wrapper for reusable content
Schema landingPage.ts Reference to reusable blocks in content array
Frontend CallToAction.tsx Component + .projection
Frontend create-projection.ts GROQ builder handling references
Frontend PageBlocks.tsx Renderer mapping types to components
Frontend page.tsx Two-query optimization

The magic GROQ pattern:

(_type == "reference" && @->_type == "reusablePageBlock") => @-> {
  ...content[0] { ${projection} },
  "invertColor": coalesce(invertColor, content[0].invertColor),
}

This dereferences the reference, extracts the wrapped block, and makes reusable blocks transparent to the frontend - they render exactly like inline blocks.


File References (from sanity.io/www)

  • Schema: packages/sanity-config/src/schemas/documents/reusablePageBlock.tsx
  • Landing Page Schema: packages/sanity-config/src/schemas/documents/landingPage.ts
  • Used On Pages Input: packages/sanity-config/src/components/input/UsedOnPagesInput.tsx
  • Projection Builder: apps/web/src/app/components/page-blocks/index.tsx
  • Resolver Utility: apps/web/src/app/utils/create-resolver-projection.ts
  • Page Renderer: apps/web/src/app/(marketing)/page/[...slugs]/LandingPage.tsx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment