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 (oldNamenewName)
  • 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 →