How to Test Schema Changes in a Headless CMS Project
A junior developer renames a field from `author` to `authorRef`, ships the schema, and by lunch the production build is throwing a wall of type errors while editors stare at empty reference fields on a hundred live documents.
A junior developer renames a field from `author` to `authorRef`, ships the schema, and by lunch the production build is throwing a wall of type errors while editors stare at empty reference fields on a hundred live documents. Nobody wrote a migration. Nobody tested the change against real content. The rollback is manual, and the postmortem is long. This is the everyday failure mode of schema changes in a headless CMS: the model is code, the content is data, and the two drift apart the moment you forget that a schema edit is a database migration wearing a friendlier name.
Schema changes are uniquely dangerous because they are invisible until they aren't. A frontend bug shows up in one component; a bad schema change quietly invalidates queries, breaks references, and strands documents that were authored under the old shape. Sanity treats the content model as versioned, typed code with a real query layer over live data, which is exactly what makes schema changes testable rather than hopeful. This article reframes schema testing as a pipeline, not a vibe check: validate the model in isolation, diff it against real content, run migrations against a copy, then promote with confidence.
Why schema changes break differently than code changes
Application code fails loudly and locally. You push a bad function, a test goes red, a stack trace points at a line number, and the blast radius is usually one feature. Schema changes fail on a delay and at a distance. When you drop a field, split an object into two, or change a field from a string to a reference, the code compiles fine and the Studio may even load. The damage lives in the content that already exists: thousands of documents authored under assumptions your new schema no longer honors. A query that expected `body` to be a string now hits an array. A reference that pointed at a `page` now dangles because you renamed the target type. None of that surfaces in a unit test that runs against fixtures you wrote this morning.
The second reason schema changes are different is that content is shared state across many surfaces. The same document feeds a website, a mobile app, a search index, and increasingly an agent or LLM pipeline reading structured fields. A field rename that looks trivial in the editor can silently null out a downstream integration nobody on your team owns. This is the core argument for treating the model as a first-class engineering artifact. In Sanity the model is defined with `defineType` and `defineField` in version-controlled TypeScript, so a schema change is a reviewable diff in a pull request, not a click in an admin panel that leaves no trace. That diff is where testing starts: you can see exactly which types and fields moved before a single document is touched.
The practical consequence: you need to test three things separately, the schema definition itself, the compatibility of that schema with existing content, and the migration that reconciles the two. Conflating them is how the lunchtime outage happens.
Validate the model in isolation before it touches content
The cheapest test is the one that never queries a document. Before you worry about migrations, prove that the schema definition is internally coherent: required fields are actually enforced, validation rules reject the inputs you expect them to reject, and references point at types that exist. Because Sanity schemas are plain TypeScript modules built from `defineType` and `defineField`, you can import them directly into a test runner like Vitest or Jest and assert against them without spinning up the Studio. You can check that a field's `validation` rule fails an empty value, that an enum `list` contains the options a downstream component depends on, and that a slug source resolves to a real field name.
This is where TypeGen earns its place in the pipeline. TypeGen reads your schema and your GROQ queries and generates TypeScript types for both, so a schema change that breaks a query surfaces as a compile error in CI rather than a runtime null in production. When you rename `author` to `authorRef`, every query that still selects `author` fails `tsc` immediately. That turns a whole class of schema-drift bugs into red builds, which is the entire point of typing the boundary between model and application. The custom input components and Structure Builder configuration that make Sanity Studio a real React app you ship are also just code, so they sit under the same test and type-check discipline as everything else.
The discipline here is to fail fast and locally. A schema test suite that runs in seconds on every commit catches the renamed field, the orphaned reference, and the loosened validation rule before any of them reach a dataset that contains real editorial work. Isolation testing does not prove the change is safe for existing content. It proves the change is coherent, which is the prerequisite for everything that follows.
Test against real content with datasets and GROQ
A schema that passes isolation tests can still be wrong for your content. The only way to know is to run the new model against the documents you actually have. This is where a copy of production content becomes the most valuable fixture you own. In Sanity you can export a dataset or clone it into a separate testing dataset, then point a branch of your Studio and application at that copy. Now the new schema meets ten thousand real documents instead of the three you hand-wrote, and the awkward cases surface: the legacy documents missing the field you assumed was always present, the rich text that uses a mark your new Portable Text config dropped, the reference that points at a type you just deleted.
GROQ is what makes this auditable rather than guesswork. Because the Content Lake is queryable and schema-aware, you can write a projection that asks for exactly the shape your new schema expects and count how many documents fail to produce it. A query like `count(*[_type == "post" && !defined(authorRef)])` tells you precisely how many documents a rename would strand, in one round trip, before you run anything destructive. You can use `references()` to find every document that points at a type you plan to change, and `defined()` and array projections to find documents whose shape predates your new assumptions. This is a level of pre-flight introspection that flat REST endpoints and fixed query layers make tedious.
The workflow is: clone the dataset, run the new schema against it, and use GROQ audit queries to quantify the gap between the model and the content. The number those queries return is your migration scope. If it is zero, the change is additive and safe. If it is ten thousand, you have a migration to write and test before anyone merges.
Write and test the migration, not just the schema
Most schema changes that touch existing content need a migration, and the migration deserves as much testing as the schema. Sanity ships a migration tooling model where you author a migration as code that describes the transformation, a rename, a type coercion, a reference rewire, or a field split, and run it against a dataset. The critical rule is that you test the migration against the cloned dataset first, never against production. Run it, then run your GROQ audit queries again. The count of noncompliant documents should drop to zero. If it does not, the migration is incomplete, and you found that out in a disposable copy instead of a live outage.
Migrations should be idempotent and reversible wherever the transformation allows it. An idempotent migration can be run twice without double-applying, which matters because real migrations get interrupted and re-run. Where a change is genuinely lossy, dropping a field that held data, the test is not whether the migration succeeds but whether you captured the old value somewhere first, in an export or a shadow field, so a rollback has something to restore from. Content Releases and scheduling let you stage the schema change and the migrated content together and promote them as a governed unit, so the model change and the content change land atomically rather than in the racey window where one is live and the other is not.
The testing loop is tight and repeatable: clone, migrate, audit, and only when the audit query returns clean do you promote. Serverless Functions can automate parts of this, running enrichment or backfill on documents as they change, but the same rule holds, exercise the Function against a test dataset with real shapes before it runs against the content your editors depend on.
Bake schema testing into CI and staged promotion
Ad hoc testing is better than none, but the failure mode returns the moment someone is in a hurry. The durable fix is to make schema testing a gate in continuous integration so it runs whether or not anyone remembers to run it. A practical pipeline has three stages. First, on every pull request, run the isolation tests and the TypeGen type-check so an incoherent schema or a query that no longer matches the model turns the build red before review. Second, on merge to a staging branch, deploy the Studio against a cloned dataset and run the GROQ audit queries as an automated step, failing the pipeline if any document would be stranded by the change. Third, gate production promotion on a clean audit and a tested migration.
This maps cleanly onto how Sanity separates the model from the content and the environment. Datasets give you isolated copies to test against. Studio Workspaces let you target different datasets from the same codebase, so staging and production are configuration, not forked repositories. Roles and Permissions and Audit logs mean the promotion step is governed and traceable, you can see who ran which migration against which dataset and when, which is the paper trail an incident review actually needs. Because the schema, the queries, and the migrations are all code in the same repository, the whole thing lives under one review and one CI system rather than scattered across an admin UI and a spreadsheet of manual steps.
The goal is that a dangerous schema change is impossible to ship silently. It either passes the isolation tests, the type-check, and the content audit, or the pipeline stops it. That is what turns schema changes from a source of lunchtime outages into a routine, boring, well-instrumented part of shipping.