DEV Community

Cover image for How I Handle Sanity Content Versioning Without Breaking Client Pages
Nayan Kyada
Nayan Kyada

Posted on • Edited on • Originally published at nayankyada.com

How I Handle Sanity Content Versioning Without Breaking Client Pages

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 }]
  )
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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.

Running the eventual migration safely

Coalescing buys time, but the ledger has to be settled — old shapes should eventually be migrated so the compatibility code can be deleted. The migration script itself is where teams get hurt, so mine follows the same shape every time: dry-run first, patch in small transactions, verify with the same GROQ that found the work.

// scripts/migrate-hero.ts
const stale = await client.fetch(
  `*[_type == "page" && defined(heroImage) && !defined(hero)]{ _id, heroImage }`
);
console.log(`${stale.length} documents to migrate`);
if (process.argv.includes("--dry-run")) process.exit(0);

for (const batch of chunk(stale, 50)) {
  const tx = batch.reduce(
    (tx, doc) =>
      tx.patch(doc._id, (p) =>
        p.set({ hero: { image: doc.heroImage } }).unset(["heroImage"])
      ),
    client.transaction()
  );
  await tx.commit();
}
Enter fullscreen mode Exit fullscreen mode

Three habits that have each earned their place by preventing a real incident:

  • Export first. sanity dataset export production backup.tar.gz before any write. Patches have no undo; the export is the undo.
  • Migrate drafts too. The query above only touches published documents — drafts. IDs carry the old shape as well, and a migration that skips them resurrects the legacy format the next time an editor publishes an old draft. Run the same patch over *[_id in path("drafts.**")] variants.
  • Keep the coalesce one release past the migration. Deploy the migrated data while the tolerant query still runs, verify the stale-document count reads zero for a few days, then delete the compatibility layer. Removing the safety net in the same deploy as the migration means a single missed document becomes a runtime error instead of a silently-handled fallback.

The full cycle — coalesce, migrate, verify, simplify — usually spans two or three releases. That's not slowness; that's the deliberate pace that makes schema evolution boring, which on a client project is exactly what it should be.

Top comments (0)