How I Handle Sanity Content Versioning Without Breaking Client Pages
May 05, 2026 · 5 min read
When you ship a Sanity-backed site and a client adds content for six months, then asks for a schema change—adding a field, renaming a reference, restructuring an object—you face a choice: migrate everything in one pass or handle both old and new shapes gracefully in queries and components. I do the latter. Here's the pattern I use to roll schema changes without downtime, rebuild churn, or runtime null-ref explosions.
The problem with naive schema updates
Say you have 140 published blog posts. Each has a category reference that points to a category document. The client now wants multiple categories per post. You change the schema from a single reference to an array of references. You push the change to Sanity Studio. Editors start adding categories to new posts. But 140 old posts still have category as a single object—not an array. Your GROQ query expects an array. Your React component maps over it. Old posts throw Cannot read property 'map' of undefined in production.
A full migration script is the clean solution, but in a live site with continuous publishing, you may not get a maintenance window. Editors may publish during your migration. You need runtime resilience.
Pattern: Query both shapes, normalise in Next.js
I write GROQ projections that handle both old and new schema shapes, then normalise to a single shape in the route or component. Sanity's coalesce and conditional projections make this trivial.
*[_type == "post" && slug.current == $slug][0] {
_id,
title,
slug,
// Old shape: category is a single reference
// New shape: categories is an array of references
"categories": coalesce(
categories[]->{ _id, title, slug },
[category->{ _id, title, slug }]
)
}If categories (array) exists, use it. If not, wrap the old category (single ref) in an array. The consumer always receives an array. No runtime checks in components. No null guards. One canonical shape.
This works for:
- Single → array migrations
- Renamed fields (
oldName→newName) - Optional → required (with fallback)
- Nested object restructuring
Handling renamed fields without breaking old documents
Client renames featuredImage to heroImage. You update the schema. New posts use heroImage. Old posts still have featuredImage populated. Query:
*[_type == "page"][0..20] {
_id,
title,
"heroImage": coalesce(heroImage, featuredImage) {
asset->{ url, metadata { dimensions, lqip } },
hotspot,
alt
}
}Coalesce tries heroImage first. If null, falls back to featuredImage. Your Next.js component references heroImage only. Old content works. New content works. No editor has to touch 80 pages.
Versioned object shapes with select
Sometimes you add a new field to an object and need different rendering logic. Say cta used to be { text, url }. Now it's { text, url, variant } where variant controls button style. Old documents have no variant. You want a default.
// app/(marketing)/[slug]/page.tsx
import { client } from '@/lib/sanity/client'
import { notFound } from 'next/navigation'
interface CTAData {
text: string
url: string
variant: 'primary' | 'secondary'
}
const query = `
*[_type == "landingPage" && slug.current == $slug][0] {
_id,
title,
cta {
text,
url,
"variant": coalesce(variant, "primary")
}
}
`
export default async function LandingPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const page = await client.fetch<{ cta: CTAData }>(query, { slug })
if (!page) notFound()
return (
<section>
<h1>{page.title}</h1>
<a
href={page.cta.url}
className={page.cta.variant === 'secondary' ? 'btn-secondary' : 'btn-primary'}
>
{page.cta.text}
</a>
</section>
)
}Old documents without variant get "primary" at query time. TypeScript sees variant as non-optional. No ternary fallback needed in the component. The normalisation lives in GROQ, not scattered across 12 components.
When to migrate vs. when to coalesce
I coalesce when:
- Schema change affects <30% of documents
- Editors are actively publishing and can't pause
- Old shape and new shape map cleanly (single → array, rename, add optional field)
I write a migration script when:
- The old shape is semantically wrong (wrong reference type, broken URLs)
- Performance suffers from dual-shape queries (rare, but possible with deep nesting)
- The client wants to bulk-update content anyway (e.g., re-tag 200 posts)
Sanity's mutations API makes migrations straightforward. I run them in a one-off Node script, not in the Next.js app:
// scripts/migrate-category-to-categories.ts
import { client } from '../lib/sanity/client'
const posts = await client.fetch(`*[_type == "post" && defined(category)]{ _id, category }`)
for (const post of posts) {
await client
.patch(post._id)
.set({ categories: [{ _type: 'reference', _ref: post.category._ref }] })
.unset(['category'])
.commit()
}Run it once, test on a staging dataset, then run on production. After migration, remove the coalesce from your GROQ. The old field is gone.
Why this matters in client work
Clients change requirements mid-contract. A schema update shouldn't mean a day of manual edits or a risky production deploy. Coalesce patterns let you:
- Ship schema updates without coordinating with content editors
- Avoid rebuild loops (ISR/PPR pages stay cached)
- Keep TypeScript strict (one interface, no optional sprawl)
I've used this pattern on four Sanity sites. Saved 8–12 hours per project in migration overhead. Editors never notice. Pages never break.
Related posts
All posts →How I reduced Sanity webhook fanout by batching revalidation tags in a single route handler
May 09, 2026 · 5 min read
A pattern for consolidating dozens of per-document Next.js revalidations into one tagged endpoint, cutting webhook noise and deploy times by 70%.
How I Eliminated Sanity Image Hot-Spot Reflows by Pre-Calculating Focal Crops
May 08, 2026 · 5 min read
How I pre-calculate Sanity image hot-spot crops at build time to eliminate layout shift and guarantee stable LCP under 2.5s on editorial pages.
How I replaced Sanity's default slug generator with a custom validation hook to catch duplicate slugs before publish
May 07, 2026 · 4 min read
A pattern for enforcing unique slugs across Sanity document types using a custom validation function and GROQ query, preventing duplicate URL conflicts at schema level.