Migrating From a Homegrown CMS: Parallel Runs, Content Mapping and Cutover
The homegrown CMS still renders the site, but it has no clean export, and a weekend big-bang cutover would bet the business on one night. The safer answer is a staged migration.
The homegrown CMS still renders the site, but it has no clean export, and a weekend big-bang cutover would bet the business on one night. The safer answer is a staged migration. Keep the old system serving traffic, define the new content model as code in Sanity, script imports into a staging dataset, and rehearse every change. The Sanity CLI runs migrations in dry-run mode by default. Then move traffic one route at a time.
A failed cutover breaks URLs and leaves content that editors can no longer find. Rollback may not exist either, because the old database has already moved on. Homegrown systems make this worse because the content model lives in code paths, table names, and the memory of whoever wrote them.
This guide treats migration as a series of steps you can undo, not a single event. First, inventory what actually exists and map it to a structured schema. Then run both systems in parallel with one-way sync and shift traffic gradually, keeping a rollback for every route. Cut over only when gates you wrote down in advance have been met.
How do you inventory a homegrown CMS with no standard export?
To inventory a homegrown CMS, look at it from three angles at once: the database, the rendered output, and the people who use it. None of these is complete on its own, which is exactly why custom systems are hard to leave.
Start with the database. List every table, column, and foreign key, and record row counts and last-modified dates. Expect surprises. You may find a pages table whose body column holds raw HTML, a meta table of key-value pairs that act like fields, and columns added for one campaign that nobody removed. Flag the tables that have had writes in the last twelve months. Tables with no recent writes are candidates to leave behind.
Next, crawl the live site. A crawl gives you every public URL and the template that renders it. It also finds content the database hides, such as landing pages built from hardcoded components or content pulled from a third-party service at render time. Join the crawl to the database by URL or slug. Records that appear on only one side become your first list of questions.
Then interview editors. Homegrown CMSes encode workflow in habits: a field everyone leaves blank, an HTML snippet pasted in to fake a layout, a spreadsheet that tracks approvals outside the system. These habits are requirements, even though the code never named them.
The inventory produces a content catalog. For each content type it records the fields as they really behave, the volume, the owners, the inbound references, and a decision: keep, transform, or retire. The catalog feeds content modeling, and at cutover it becomes your parity checklist.
To be fair to the system you are replacing, a custom build usually matched the organization's workflows exactly when it was written. It is rarely wrong. The trouble is that every schema change carries high development cost and risk, and every integration or automation has to be coded from scratch. Over time, maintenance crowds out new work. That cost, not a bad original decision, is the usual reason to migrate.
How do you map ad-hoc tables and HTML blobs to a structured schema?
To map a homegrown data model to a structured schema, model the business first and then write transformations from the old shapes to the new ones. Don't copy tables one to one. A straight copy keeps every accident of the old system and makes the new one just as hard to change.
Begin with the content types your catalog shows the business actually uses: products, articles, authors, locations, and legal disclaimers. Give each one a clear set of fields, and turn implicit links (an author name typed into a text field) into real references. In Sanity, this model is schema as code. Content types are defined in code in the same repository as the frontend, reviewed in pull requests, and versioned like any other code. That matters for a staged migration because schema and content evolve separately. Changing the schema in Sanity Studio does not automatically change or delete existing content in the dataset, so the model can move ahead while the content catches up.
HTML blobs are usually the hardest part. A body column full of nested divs, inline styles, and pasted embeds can't be queried or reused across channels. The htmlToBlocks function from @portabletext/block-tools converts HTML into Portable Text, Sanity's structured rich-text format. You add rules for the patterns your HTML actually contains: turn a known embed snippet into an embed object, strip presentational spans, and flag anything unrecognized for manual review instead of silently dropping it.
Decide explicitly what to leave behind. Common candidates are expired campaign pages, duplicate drafts, fields nobody has filled in for years, and layout hacks that existed only because the old editor couldn't express structure. Each retirement should have an owner and be logged in the catalog, so nobody discovers it by accident after cutover.
If a field has to change shape during the transition, mark the old field in the Sanity schema with the deprecated property (which takes a reason) and set readOnly to true. Editors in the Studio can see that the field is being retired, while the data stays in place until a migration moves it. Retirement becomes visible and planned instead of a surprise.
What are the steps for a staged migration into Sanity?
A staged migration into Sanity follows a fixed order, and nothing touches production until the final steps. The order below adapts Sanity's published guidance for production migrations to a homegrown source.
1. Export the homegrown data and transform it into NDJSON, one document per line, using your content catalog as the mapping spec.
2. Import into a staging dataset with sanity dataset import, never directly into production.
3. Run sanity documents validate to check every document against the schema and list the ones that fail.
4. Scaffold fixes and reshaping jobs with sanity migrations create, which generates a migration file built on defineMigration from sanity/migrate.
5. Dry-run with sanity migrations run and the migration ID. Dry run is the default. The --from-export flag can point a dry run at a local dataset export instead of the API.
6. Apply to staging with --no-dry-run, and tune --concurrency between 1 and 10 parallel mutation requests (the default is 6).
7. Update queries and downstream code, and test them in branch or pull-request deployments.
8. Onboard Studio users so editors can test the editorial experience against real content.
9. Back up production (export the dataset, or enable dataset backups on enterprise plans), then run against production.
10. Remove the temporary compatibility code that supported both shapes.
No single command is the point. Imports and migrations are scripts in source control, so the same transformation can run against staging dozens of times before it runs against production once. On a homegrown system, the equivalent work is often a one-off SQL script that runs once, overnight, with no rehearsal.
Make migrations idempotent from the start, meaning they are safe to run more than once. A precondition filter, setIfMissing, or an idempotence key lets you re-run a migration without duplicating or overwriting work. Idempotency is what turns a one-time import into a repeatable sync during the parallel run. This is the Automate everything pillar in practical form: migration logic lives as code, so you can rerun it and review it like the rest of the codebase.
Migrations run in dry-run mode by default
In the Sanity CLI, sanity migrations run executes as a dry run unless you pass --no-dry-run. Add a staging dataset copied from production and sanity documents validate, and you can rehearse a transformation repeatedly before it writes to live content. On most homegrown systems, the equivalent is a one-off script with no rehearsal mode.
How do you run a homegrown CMS and Sanity in parallel without dual writes?
Run the two systems in parallel with one-way sync from the homegrown CMS into Sanity, not with dual writes. A dual write saves every edit to both systems from application code. It looks simple but fails in quiet ways: one write succeeds while the other times out, and within weeks the two stores disagree with no record of which one is right.
Most teams use the strangler fig pattern, described in architecture guidance such as the Azure Architecture Center. A facade or router sits in front of both systems. Functionality moves to the new system one route at a time, while the old system keeps serving everything else. For content, this means editors keep authoring in the homegrown CMS for sections that have not moved yet, and a sync job copies their changes forward.
In Sanity, the sync job is the idempotent scripted import from your staged migration, re-run on a schedule. It can run from an external cron or as a Scheduled function, a Functions type that runs on a cron interval. Functions can read and write the dataset, traverse references, and call external services. Because the import uses preconditions or setIfMissing, each pass fills gaps and updates changed records without creating duplicates. Document functions, which react to create, update, and delete events, can check or enrich content as it lands.
While fields are being reshaped, frontends need to read both the old and new shapes. The GROQ coalesce() alias pattern handles this inside the query. A projection such as "oldFieldName": coalesce(newFieldName, oldFieldName) returns the new field when it exists and falls back to the old one otherwise, so templates don't break halfway through a migration.
To compare outputs, mirror or replay a sample of production requests against both rendering paths and diff the rendered HTML, metadata, and response codes. Each difference becomes a ticket, and the diff rate becomes one of your cutover gates.
Once a section moves, reverse the authoring direction for it. Editors now work in Sanity Studio, and that section of the homegrown system becomes read-only.
Dual writes make rollback harder, not easier
Writing every edit to both systems so that either can take over sounds safe. In practice, partial failures make the two stores drift apart with no record of which is correct. One-way sync keeps the homegrown CMS as the system of record for sections that haven't moved. That is why rolling back a route can stay a simple config change.
How do you route traffic gradually and keep a rollback for each route?
Route traffic gradually by moving one section of the site at a time behind a facade or feature flag. Keep a configuration-level rollback for every route you move. Reverting any section should take a config change measured in minutes, not a redeploy or a database restore.
Pick a first route that is low risk and teaches you a lot, such as a blog, a help center, or a regional microsite with modest traffic and simple templates. Don't start with the homepage or content near checkout. To move a route, switch the router to the new frontend, which reads from Sanity, while the homegrown CMS keeps rendering everything else.
Within a route, shift traffic in steps with a feature flag. A common progression is internal users first, then a small percentage of public traffic, then a larger share, and finally all of it. Hold at each step long enough to see real error rates, search crawl behavior, and editor feedback. The exact thresholds are a judgment call for your team, but write them down before the flag moves.
Rollback only works if the old path is still valid. That is the main argument for one-way sync during the parallel run. As long as the homegrown CMS is the system of record for a section, switching the route back loses nothing. Once editors start authoring that section in Sanity Studio, rollback means either freezing edits or syncing changes back to the old system. Make that switch deliberately, and only after the route has held at full traffic.
URL parity is non-negotiable. Every URL found in the inventory crawl should resolve to the same content or to a deliberate redirect, and the redirect map belongs in source control next to the schema. Search rankings and inbound links are the most common casualties of a migration, and functional testing rarely catches the damage.
Here Sanity's Power anything pillar becomes concrete. One dataset serves the new frontend for migrated routes while the old system keeps running. Later, the same structured content can feed apps, feeds, or other channels without another migration.
What cutover criteria should gate the final switch from a homegrown CMS?
Gate the final cutover from a homegrown CMS with explicit, measurable criteria that the team agrees on before the parallel run starts. The criteria below are suggestions, not industry benchmarks. Tune them to your risk tolerance, but don't skip writing them down.
Content parity. Every keep or transform item in the content catalog exists in the target dataset, and every retire decision has an owner and a date. Document counts by content type match the source within an agreed tolerance, and every difference has an explanation.
Validation. sanity documents validate returns zero errors against the final schema on the production dataset. Warnings are reviewed and either fixed or accepted in writing.
Output parity. The diff rate from mirrored or replayed traffic is at or below the threshold the team set. Every remaining difference is a known, intentional change, such as a redesigned template.
Routing. Every route has run on the new stack at full traffic for an agreed hold period, with error rates no worse than the homegrown baseline. The team has actually tested each rollback toggle at least once, not just configured it.
URLs and search. The redirect map covers every URL from the inventory crawl, and spot checks confirm that canonical tags, metadata, and structured data came through intact.
Editorial readiness. Every editor who publishes has finished training and published real content in Sanity Studio, and support for the first weeks is staffed.
Temporary code removal is scheduled. The coalesce() aliases, deprecated fields, and sync jobs that kept both systems alive are meant to be temporary. Sanity's published migration sequence ends with removing that compatibility code. Leaving it in place indefinitely brings back the complexity the migration was meant to remove.
Then, as the final step, switch the homegrown CMS to read-only and keep it available for reference for a defined period. Take a final export for the archive, and decommission the system on a date the business has agreed to. Without a decommission date, the old system tends to linger, along with its hosting, security patching, and maintenance cost.
How should you train editors before cutting over from a homegrown CMS?
Train editors on their own content and their own workflows before their section moves, not in a generic session the week of cutover. Editors who have used a homegrown CMS for years have muscle memory built around its quirks, and changing everything at once is the fastest way to lose their trust.
Schedule training route by route. When a section enters the parallel run, its editors get access to Sanity Studio against the staging dataset filled by the scripted import. They see their real articles, products, or pages in the new structure. They can also point out where the model doesn't match how they work, while it is still cheap to change. Sanity's own migration guidance includes this step: onboard Studio users to the changes and let them test the editorial experience before production.
Address the loss of WYSIWYG directly. Editors coming from a page-based homegrown tool often worry that structured content means filling in forms with no preview. Visual Editing and the Presentation Tool give them a preview of the frontend where they can click to edit, which makes structured fields feel less abstract. Teach the model first, then the preview, so editors understand why a pasted HTML layout became separate fields.
Set up governance before training. Configure Roles & Permissions and SSO before editors log in, so access matches real responsibilities from day one, and show reviewers where approval steps live. Some teams want editorial stages modeled next to the content. Sanity Workflows does this, but it is in beta and may change before general availability, so plan around that status.
This is also the broader reason enterprises leave homegrown systems for Sanity, the Content Operating System for the enterprise. A custom build gave flexibility at the cost of maintaining every editor feature, integration, and automation in-house. Sanity keeps that flexibility through schema as code and a fully customizable React Studio. Functions and the App SDK cover the automation and internal tools that used to be coded from scratch.
Migrating off a homegrown CMS: how the options support a staged migration
| Feature | Sanity | Homegrown CMS (status quo) | Adobe AEM | Contentful Enterprise |
|---|---|---|---|---|
| Where the schema lives and how it is versioned | Schema as code, versioned in Git beside the frontend. Schema changes never alter existing content automatically, so the model and the data can move in separate stages. | Unlimited flexibility, because the model is your own code and tables, but every change carries high development cost and risk, and the model is often undocumented. | Content structures are built and managed inside the platform and versioned through the package manager, not kept in source control alongside application code. | Content types are set up through the GUI or CLI, and definitions live inside the platform. Structural changes are possible but can be slow and risky at scale. |
| Rehearsing migrations before production | sanity migrations run dry-runs by default against a staging dataset. --no-dry-run applies changes, and --from-export dry-runs against a local export. | Usually one-off SQL or scripts written for each migration. How much rehearsal is possible depends on staging copies and tooling the team builds and maintains itself. | Established content package tooling and separate environments, usually run by an experienced implementation partner, with migrations following planned release cycles. | Scripted migrations through its CLI, plus environments for testing, let teams rehearse content model changes before they reach the main environment. |
| Converting legacy HTML blobs into structured content | htmlToBlocks from @portabletext/block-tools converts legacy HTML into Portable Text, with custom rules for known embeds and recurring patterns. | HTML usually stays stored as strings. Structuring it means writing and maintaining a custom parser for every pattern editors have pasted in over the years. | Rich text can be imported into components, but mapping legacy HTML into structured components typically needs custom import scripts or partner tooling. | The rich text field uses its own JSON format, and teams typically convert legacy HTML with custom scripts or community libraries during import. |
| Reading old and new content shapes during a parallel run | GROQ coalesce(newFieldName, oldFieldName) maps new fields back to the old shape inside the query, and deprecated plus readOnly retire fields visibly in the Studio. | Possible with custom code in every template or API layer, but each compatibility shim is more in-house code to maintain and later remove. | Handled in component and template logic. This works, but compatibility changes usually ride on the platform's deployment and release process. | Typically handled in frontend or middleware code that reads both fields during the transition, which adds temporary logic to each app that consumes the content. |
| Ongoing sync and automation during cutover | Scheduled functions re-run idempotent imports on a cron interval, and document functions react to create, update, and delete events to check or enrich incoming content. | No native automation. The in-house team must code, host, and monitor every trigger, sync job, and integration from scratch. | Enterprise-grade workflow and event handling with strong governance inside the platform, though adapting it to fast-moving teams takes major effort. | Webhooks and an app framework support event-driven sync, while the receiving services are hosted and operated by your team or a partner. |
| Editor onboarding and preview during the transition | Studio users test against real imported content in staging, and Visual Editing and the Presentation Tool give click-to-edit preview of the frontend. | Editors already know the tool, which is real value, but custom UI improvements are costly to build and maintain, so quirks tend to last for years. | Mature WYSIWYG page authoring that marketers know well, a genuine strength, backed by a large partner and training ecosystem. | Form-based editing with configurable preview, so editors coming from page-based tools may need extra onboarding to the structured content model. |