We moved a few hundred marketing pages out of committed React files and into a content API. Every page returned 200 before the migration, during it, and after it. So did the four things we broke.
Status codes are the wrong instrument for this job, and the reason is structural rather than careless: a good migration keeps a fallback, and a fallback renders the same page. This post is the list of what actually goes wrong, for anyone moving static pages into a CMS behind a framework that caches.
The pattern, which is worth copying
Each page family has three parts:
lib/<family>-content.ts, the committed copy, unchanged from before the migration.lib/<family>-content-live.ts, which fetches the row from the API and falls back to the committed module on any failure.- A thin server component that calls the live module and passes the result to a client component.
The property this buys is worth the work: an API outage costs freshness, not availability. Pages keep rendering, from copy that was correct on the day of the last deploy.
It is also the source of every problem below, because a fallback that renders identically is indistinguishable from success.
A 200 does not mean the page read the database
Both copies came from the same source, so the live row and the committed module produce byte-identical output on day one. A page reading its fallback looks exactly like a page reading the API.
We found four pages with a finished client component, a correctly seeded row, and no server wrapper importing the live module. The client component was dead code. Every edit in the content studio changed nothing, and the pages looked perfect throughout.
Status is the wrong assertion. Assert provenance:
- In a non-production environment, mutate the row deliberately and diff the rendered output. If the page does not change, it is not reading the row.
- Or emit provenance explicitly. A response header or a data attribute carrying
source=api|fallbackcosts one line and turns a manual check into a crawl. - Grep for the structural version of the bug. Every
*-page-client.tsxwhose siblingpage.tsxdoes not import it is a page wired to nothing.
Deleting the directory removes the URL from the sitemap, silently
Our sitemap was generated by walking app/ for page.tsx files, skipping dynamic segments. It is a perfectly ordinary implementation and it is incompatible with this migration by construction: the whole point is that the page no longer has a file.
Fifteen URLs left the sitemap the day their directories were deleted. Every one of them still returned 200. There is no error anywhere in this sequence, and the only external signal is a slow decline in crawl coverage weeks later.
The fix is to union the two sources:
const fromFiles = walkAppDirectory(); // still correct for real files
const fromApi = await fetchPublishedSlugs(TYPES); // the rows
const urls = dedupe([...fromFiles, ...fromApi]);
Then go and look at every other script that enumerates the filesystem the same way. Ours had three: a JSON-LD checker, an orphan page finder, and a broken link crawler. All three quietly stopped covering the migrated pages, which means they kept reporting green over a shrinking surface.
"Row absent" and "API unreachable" are not the same failure
A reader that returns null for both collapses two different situations into one, and the framework then caches the result.
The sequence: content service restarts, page requests during the restart get nothing back, the reader returns null, the page renders its not-found branch, and the framework caches that 404 for the full revalidation period. The service comes back healthy in forty seconds. The pages stay 404 for an hour.
Return an outcome rather than a nullable:
type Outcome<T> =
| { status: 'found'; item: T }
| { status: 'absent' } // the row genuinely does not exist: 404 is correct
| { status: 'unavailable' }; // fetch failed: fall back, do not cache a 404
The unavailable branch falls back to the committed module and, importantly, does not let the framework cache that render as canonical.
Dates drift twice, in opposite directions
Two separate problems that look like one.
The storage column was a zone-less timestamp holding UTC, while the source frontmatter carried ISO instants with offsets. Wherever the two met, dates either lost their hour or got stamped with the time of the import run. A blog archive where every 2019 post is dated 09:14 on migration day is the visible version. The invisible version is ordering.
Second, and more damaging: the row has an updatedAt column that changes on any write, including bookkeeping ones. Publishing that as dateModified in structured data, or as lastmod in the sitemap, tells every crawler that your entire corpus changed today. Keep the editorial modified date as a separate field that only a human editing the content sets.
A converter reporting complete conversion is not evidence
Our conversion script reported every page as converting cleanly. It was reporting that it had produced output, which is a different claim from having preserved the content.
Render the page from the database, diff it against the live page, and only then delete the directory. We did this on a sample and found copy that the converter had dropped without complaint. The general rule: a migration tool's success metric is its own output, so it cannot tell you whether the migration worked. Only a comparison against the old artefact can, and once the directory is deleted the old artefact is gone.
The concession: the fallback looks like dead code
Six months from now, someone will run a dead code check, find a committed content module that nothing appears to reference at runtime, and delete it. It is exactly the sort of cleanup that looks obviously correct in review.
It is not dead code. It is the reason an API outage is a freshness problem rather than an outage. Leave a comment at the top of every one of those files saying so, in plain language, naming the failure it prevents. A comment is a weak defence, and it is the only one available against a reviewer who is right about everything except the thing you did not write down.
The implication
Every failure here shares one property: the system returns the correct output through the wrong path. That is what makes the migration dangerous and it is also what makes it safe, since the same fallback that hides the bug is what keeps the site up.
So the checks have to test the path, not the output. Mutate a row and look for the change. Diff the sitemap against the union of files and published slugs. Restart the content service in staging and see what the site caches. None of these are expensive, and none of them are the check anyone runs by default, which is loading the page and seeing that it looks right.