Migrating Content Models Without Breaking the Frontend

This guide, part of Content Modeling Best Practices, describes how to change a content model that is already in production, renaming fields, splitting types, converting embedded objects to references, without a moment where the deployed frontend and the stored content disagree.

A content model change has two halves that deploy separately: the CMS schema and content on one side, the frontend code on the other. There is always a window where one has changed and the other has not, and editors keep publishing throughout. The safe pattern is the same one used for database migrations: expand the model so both shapes are valid, move the data, switch the frontend, then contract the model. Each step is small, reversible and deployable on its own.

Expand, migrate, switch, contractA model migration in four overlapping phases over two weeks: the model is expanded with the new field on day one, content is migrated on days two to four, the frontend reads both shapes from day one and switches to the new one on day five, and the old field is removed on day twelve.Expand modeladd new fieldFrontend dual-readold or newMigrate contentscript, batchesFrontend new onlyOld field read-onlyeditors warned0 days2.5 days5 days7.5 days10 days12.5 dayscutovercontract
At every point in the timeline, the deployed frontend can render every piece of stored content.

The Problem

A team renamed the summary field on articles to excerpt and changed it from plain text to rich text, in one release. The CMS change went live first, as part of a scheduled model update, and the frontend deploy was delayed by an unrelated failing test. For two hours, the live site showed empty excerpts on every article listing, because the deployed code read summary. Worse, editors who published articles during that window wrote into excerpt, and when the team rolled the model back, those edits were lost.

How Safe Migrations Work

Every model change can be expressed as a sequence of steps that keep both halves compatible.

Expand. Add the new field or type alongside the old one. Nothing reads it yet, so nothing breaks. Deploy the model change through a migration script, first to a branch environment and then to production.

Dual-read. Deploy frontend code that reads the new field when present and falls back to the old one. The site renders every entry correctly whether it has been migrated or not.

Migrate. Run a script that fills the new field from the old one for every entry, in batches, preserving publish state: published entries are republished, drafts stay drafts. Mark the old field read-only or hide it in the editor so new edits go to the new field.

Switch and contract. Once all entries are migrated and verified, deploy frontend code that reads only the new field, then remove the old field from the model. If anything goes wrong before the contract step, rolling back is trivial because the old data is still there.

Migration steps and their rollbackEach step from expand through dual-read, migrate, switch and contract can be rolled back independently until the old field is removed.Expandadd excerptDual-readexcerpt ?? summaryMigratescript, batchesSwitchread excerptContractremove summaryafter verification
Only the final step is irreversible, and it runs after everything else has been verified in production.

Implementation

Model and content changes live in versioned migration scripts, run by CI against a branch environment created from production. The example uses Contentful’s migration tooling; Sanity, Strapi and Hygraph offer equivalent schema and content APIs.

JavaScript
// migrations/2026-09-12-excerpt.cjs: expand step (run with `contentful space migration`)
module.exports = function (migration) {
  const article = migration.editContentType("article");
  article.createField("excerpt").name("Excerpt").type("RichText").localized(true);
  article.changeFieldControl("summary", "builtin", "singleLine", {
    helpText: "Deprecated: use Excerpt. This field will be removed on 2026-09-24.",
  });
  article.moveField("excerpt").afterField("title");

  // Content step: copy summary into excerpt as a single rich text paragraph.
  migration.transformEntries({
    contentType: "article",
    from: ["summary"],
    to: ["excerpt"],
    shouldPublish: "preserve",
    transformEntryForLocale(fields, locale) {
      const text = fields.summary?.[locale];
      if (!text) return;
      return {
        excerpt: {
          nodeType: "document",
          data: {},
          content: [{ nodeType: "paragraph", data: {}, content: [{ nodeType: "text", value: text, marks: [], data: {} }] }],
        },
      };
    },
  });
};

The frontend reads both shapes during the transition, in one mapping function, so no component changes twice.

TypeScript
// lib/cms/article-mapper.ts
import type { Document } from "@contentful/rich-text-types";

interface RawArticle { fields: { title: string; summary?: string; excerpt?: Document } }

export function toExcerpt(raw: RawArticle): Document | null {
  if (raw.fields.excerpt) return raw.fields.excerpt;
  if (raw.fields.summary) {
    // Transitional: remove after the contract step on 2026-09-24.
    return {
      nodeType: "document" as Document["nodeType"],
      data: {},
      content: [{ nodeType: "paragraph", data: {}, content: [{ nodeType: "text", value: raw.fields.summary, marks: [], data: {} }] }],
    } as Document;
  }
  return null;
}

Testing the migration before production

Create a branch environment from production, run the migration there, point a preview deployment of the dual-read frontend at it, and compare pages. A visual diff of the most visited pages between production and the migrated branch catches mistakes in the transform function. With environment aliases, the whole migrated environment can even become production by switching the alias, which makes the cutover atomic, as long as edits made in the old environment during the migration are replayed or frozen.

Configuration Reference

Step Where it runs Rollback
Expand migration script, branch then production Delete the new field.
Dual-read frontend deploy Redeploy previous frontend.
Migrate content script, batches, preserve publish state Old field still holds the data.
Freeze old field editor UI, help text, read-only Re-enable editing.
Switch frontend deploy Redeploy dual-read frontend.
Contract migration script after verification Restore from backup only.

Gotchas & Edge Cases

  • Publishing during migration. Editors publishing between the migrate and freeze steps can write to the old field after it was copied. Freeze first, or run the content step twice, the second time for recently updated entries only.
  • Losing publish state. A script that publishes every migrated entry also publishes drafts and pending changes. Preserve state: republish only entries that were published and unchanged.
  • Webhook storms. Migrating ten thousand entries fires ten thousand publish webhooks. Pause webhooks or let the debouncing layer collapse them, and run a single full revalidation afterwards.
  • Forgetting the contract step. Dual-read code and deprecated fields accumulate. Put the contract date in the help text and the code comment, and track it as a ticket.

Worked Example

After the lost-edits incident, the team adopted the four-step pattern with scripts in the repository and a checklist in the pull request template. The next change, converting embedded author objects to references, ran over nine days: expand on Monday, dual-read deployed the same day, migration in a branch environment on Tuesday with visual diffs of 200 pages, production migration on Wednesday with webhooks paused, the switch on Thursday of the following week and the contract step two days later. Editors kept publishing throughout, and no page rendered an empty byline at any point.

Pages with rendering problems during each migrationThe number of live pages showing empty or broken content during the summary-to-excerpt rename done in one step, compared with the author-reference migration done with the four-step pattern.One-step rename3100 pages affectedFour-step migration0 pages affected
The four-step pattern removed the window in which code and content disagreed.

Keeping migrations in the repository

The most durable improvement was not any single script but the habit of keeping every model change in the frontend repository as a numbered migration, next to the code that depends on it. A pull request that changes a content type now shows the migration, the dual-read mapping and the tests together, and reviewers can see whether the contract step has been scheduled. CI runs pending migrations against a fresh branch environment on every pull request that touches the migrations folder, so a script that fails on real data fails before review, not during the production run. The migration history also answers the question that used to take hours of archaeology: when and why a field changed shape.

Treat the production run as a deploy. Announce it to editors with the time window and what will change in their interface, run it from CI rather than a laptop, and record which migration ran against which environment. When a migration environment is later promoted by switching an alias, record that too, since it changes what production serves without any code deploy.

Rollout Checklist

  • Write every model change as a versioned migration script, reviewed like code.
  • Expand the model before changing the frontend; never rename in place.
  • Deploy dual-read code before migrating content.
  • Migrate in a branch environment first and compare rendered pages.
  • Preserve publish state and manage webhook volume during content migration.
  • Freeze the old field, switch the frontend, then contract after verification.

Frequently Asked Questions

Is all this necessary for a small change?

Adding an optional field needs only the expand step. Renaming, retyping or removing anything that the frontend reads needs the full sequence, because the deployed code and the stored content will disagree at some point otherwise.

How long should the dual-read period last?

As long as it takes to migrate and verify, typically days. Keep it short enough that nobody forgets why the fallback exists, and put the removal date in the code.

Can we skip dual-read by deploying both halves at the same time?

Not reliably. Deploys and model changes never land at exactly the same moment, caches hold old responses, and editors keep publishing. Dual-read makes timing irrelevant.

What about static sites?

The same steps apply, with a rebuild after each. Dual-read code matters just as much, because a build triggered by a publish during the migration renders whatever shape the content has at that moment.

What if a migration script fails halfway?

Write scripts to be rerunnable: skip entries that already have the new value, and process in batches with progress logged. A rerun then completes the remaining entries instead of duplicating work.