A pattern for creating once, using everywhere: how to implement reusable content blocks in Sanity that can be shared across multiple pages.
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
┌─────────────────────────────────────────────────────────────────┐
│ Landing Page A Landing Page B │
│ ┌──────────────────────┐ ┌──────────────────────────┐ │
│ │ content[] │ │ content[] │ │
│ │ ├─ heroPageBlock │ │ ├─ featureSpotlight │ │
│ │ ├─ reference ───────┼────┬────┼──┤ reference ────────────┤ │
│ │ └─ articlePageBlock │ │ │ └─ quotePageBlock │ │
│ └──────────────────────┘ │ └──────────────────────────┘ │
└──────────────────────────────┼──────────────────────────────────┘
│
▼
┌────────────────────────┐
│ reusablePageBlock │
│ (document) │
│ │
│ title: "Shared CTA" │
│ invertColor: false │
│ content[0]: │
│ callToActionBlock │
└────────────────────────┘
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,
}
},
},
})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",
},
],
},
],
})Each page block component exports:
- The React component
- A
.projectionproperty (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,
`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
}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 documentcontent[0]extracts the single block from the content arraycoalesce(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
// 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>
)
})
}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} />
}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 -
useObservablehandles 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:
useObservablefromreact-rx- Subscribe to observables in ReactuseDocumentStorefromsanity- Access reactive document queriesuseFormValuefromsanity- Get current document dataIntentLinkfromsanity/router- Deep link to edit documents
Add dependency:
pnpm add react-rxRestrict 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"]',
},
}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}
/>| 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.
- 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