...
Back

The Migration That Touches Every Page

A major framework upgrade turned one synchronous API asynchronous. The codemod handled most of it. The remainder is where the actual lesson lives.

The Migration That Touches Every Page

The Migration That Touches Every Page 🔁

We went up two major versions of our framework and one of our UI library in a single change. The headline breaking change was small to describe and wide to apply: route parameters became asynchronous. Where a page used to read params.lang, it now has to await the params object first.

One property, one await. Multiplied by every route in a localized app, which is every route.


Wide-and-shallow is a different animal from deep-and-narrow

Most migrations you dread are deep: one subsystem changes semantics, and you have to think hard about a few places. This was the opposite — trivial reasoning, enormous surface. It fails differently and it should be planned differently.

The specific danger of wide-and-shallow is that it looks finished long before it is. The codemod runs, the build goes green, and the twenty call sites it could not see are all in the places automated tools are weakest: dynamic metadata functions, nested layouts, route handlers, a component that destructures params two levels down from where they arrive.

Three patterns the tooling reliably misses:

  • Params passed through a helper. The tool rewrites where params are declared, not where a wrapper you wrote hands them onward.
  • Metadata generation. It lives beside the page and often gets its own copy of the same signature, which the codemod may or may not treat as a route.
  • Anything conditional. const lang = params?.lang ?? "en" does not match the shape the rewrite looks for, so it silently stays synchronous — and now silently resolves to the fallback for every request.

That last one is the dangerous shape: it does not throw. It renders. In the default language. Forever.


Two upgrades at once was the right call, for one reason

Doing the framework and the UI library in the same change sounds reckless, and normally it is. Here it was right, because the two were coupled: the new framework major required the new library major. Splitting them would have meant an intermediate state that satisfied neither, and time spent making that state work is time spent on a configuration nobody will ever run.

The rule we would state from this: upgrade coupled things together and independent things separately. The cost of a combined upgrade is a harder bisect. The cost of splitting a coupled pair is building a bridge to an island.

What makes the combined version survivable is that everything else stays still. No feature work in the branch. No opportunistic refactors. A reviewer should be able to read the diff and see one mechanical change repeated, plus a short list of genuine exceptions — and the exception list is the actual review.


The part worth the most attention

Server components rendering something that must be client-side is the other breakage that showed up, and it is worth separating from the params change because it is a reasoning error rather than a mechanical one.

When a component can only run in the browser — a canvas animation, anything touching window — the old escape hatch was to dynamically import it with server rendering disabled. In a server component that option stops being available, because the boundary is no longer a runtime flag; it is a structural property of where the component sits.

The fix is not a flag. It is to draw the client boundary explicitly: a small client component that owns the browser-only thing, imported by the server component that lays out the page. That is more code than a flag. It is also the correct model, and the migration merely stopped letting us avoid it.


What we would do again

  • Land the mechanical change alone, with nothing else in the branch.
  • Grep for the pattern after the codemod, not before. The remainder list is short and it is the whole risk.
  • Search for the silent-fallback shape specifically — optional chaining and defaults around the changed API. Those do not fail loudly, so they will not appear in the build output.
  • Click through one page per locale, because the failure mode of this particular upgrade is content rendering in the wrong language rather than an error.

A wide-and-shallow migration is finished when you have enumerated the exceptions, not when the build is green. The build being green is the beginning of the review, not the end of the work.