Mario Kranjec

· Engineering · 6 min read

Migrating from Gatsby to Astro: the framework was the easy part

This blog ran on Gatsby v2 since 2020: React 16, Flow, and a long list of plugins. It worked, but every upgrade was a project of its own, and the build was pinned to an old Node version because Gatsby’s native dependencies broke on newer ones. There was even a plan to move it to Next.js, written down and never built.

It now runs on Astro, and porting the templates was the smallest part of the job. The work in a migration is everything the old site does that nobody wrote down. The only reliable way I know to find it is to make the new build prove it matches the old one before it goes live.

The rule: nobody should notice

Before writing any code, I wrote down who must not notice the switch:

  • Readers: every post, page, category and tag URL keeps working, pagination included, and the site looks the same.
  • Search engines: the same URLs in the sitemap, and the same title, description, canonical and og:image on every page.
  • Feed readers: /rss.xml keeps the same item guids, so nobody’s reader shows every post as new.
  • Me: if anything goes wrong, rolling back takes minutes.

That list became the acceptance test. Everything else, from a redesign to image optimisation, waited until after the cutover.

A parallel build and a single cutover

Moving page by page behind a proxy would have meant running two frameworks in production, for a site small enough to switch at once. So Gatsby kept serving production, frozen except for content. Astro lived next to it in the same repo, in its own directory with its own package.json. Both builds read the same Markdown files, so a post written during the migration appeared in both.

The Astro build got its own Netlify branch deploy, so there was always a live preview to click through. The cutover itself was one commit that pointed the Netlify production build at Astro. Rolling back would have meant reverting that commit.

A parity check as the finish line

Clicking through a preview told me a page looked right. It couldn’t tell me that all 57 URLs in the sitemap still had the right canonical, so I wrote a small script that compares two build directories:

npm run parity -- <gatsby-dist> <astro-dist>

It collects each build’s URLs (from the HTML files, the sitemap and the feed) and compares, URL by URL:

  • the <title>, meta description, canonical and og:image
  • the feed’s item guids and links, and whether each item carries the full post
  • whether a self-unregistering service worker exists at the old service-worker path

It prints every difference by URL and field, and in strict mode it exits non-zero on any of them. It has no dependencies, and its own tests run against tiny hand-written build folders, one per kind of difference it has to catch.

It ran in report-only mode on every preview build while I worked, and the cutover waited until strict mode exited with 0. It has limits I accepted on purpose: it compares head metadata and feeds, not page bodies, and it can’t see layout. I compared the post bodies separately, and checked the look by hand on the preview.

Where the work actually was

None of this was specific to Astro; any framework would have run into the same problems.

Markdown that’s almost CommonMark. Gatsby’s remark was old, and it had opinions: when a list counts as loose (paragraphs inside list items) and how bare email addresses get linked. A current CommonMark parser handles both differently, so the same Markdown came out as different HTML. I wrote small remark and rehype plugins to reproduce the old behaviour, plus the old heading anchors, external links opening in a new tab, responsive iframes and typographic quotes. Shiki replaced Prism for syntax highlighting, with Prism-compatible class names so the existing CSS kept working. In the end, the normalised body HTML of every published post matched Gatsby’s output.

Feed guids. RSS readers use an item’s guid to decide whether they’ve seen it before. Change the guids and every subscriber gets every post again. The guids stayed exactly as Gatsby generated them, and they still use the old URL form even now that posts have moved (see below).

A service worker from 2020. Gatsby’s offline plugin installed a service worker in the browser of anyone who had visited the site. Left alone, it could keep serving them a cached copy of the old site. Astro now ships a tiny sw.js at the same path that clears its caches and unregisters itself. Offline support was dropped with it.

Content the old site was too forgiving about. The Astro content collections use a strict schema, so a missing or misspelled frontmatter field fails the build instead of rendering an empty page. And one post was tagged tutorial while every other one used Tutorial. Both map to the same /tag/tutorial/ URL, so Gatsby quietly let one tag page overwrite the other. That post is now tagged Tutorial like the rest.

Deviations, decided on purpose. Matching the old site exactly would have meant copying one of its bugs: Gatsby gave every page the same canonical URL, the site root, which tells search engines that every page is a duplicate of the homepage. The new site’s canonicals point at the page itself. The parity check got an option, --canonical=self, that expects exactly that difference, so it still caught every other one.

After the cutover

Strict parity passed, the preview looked right, and the cutover went out as that one commit. Then I checked the live site by hand: posts, pages and listings returned 200, the feed’s guids were unchanged, the sitemap was complete, the old service-worker path served the self-unregistering one, and analytics still fired.

Removing Gatsby deleted almost 40,000 lines; about 30,000 of them were yarn.lock. The Astro project moved to the repo root, and the site moved to Node 26. Pages ship no framework JavaScript, just a handful of small scripts for analytics, the theme toggle and the copy button on code blocks. A production build on Netlify takes 24 seconds, down from 42 with Gatsby.

Only then did the visible changes start. The blog moved under /blog/, and every old URL now gets a single-hop 301 to its new one. The redirect rules are generated from the content, not written by hand, and the parity check gained a mode that confirms every URL of the previous build either still exists or redirects in one hop to a page that does.

If you’re about to do the same

  • Write the acceptance check before the new site. It decides when you’re done, and it finds the things nobody remembered the old site doing.
  • Freeze the old site. If both sides keep changing, you’re comparing against a moving target.
  • Cut over in one step you can undo. A single commit is easy to reason about and easy to revert.
  • Make every deviation a decision. Write it down, and make the check aware of it.

I take the same approach with client migrations. At the scale of a city guide with around 100,000 URLs, a single cutover is too big a step, so sections move one at a time behind redirects, with Search Console and the server logs watched while each one goes live.

New posts by email

On backend architecture, delivery and tech leadership. Only when there's something worth reading. Unsubscribe anytime.

Backend slowing your team down?

I work with startups and scale-ups as a contract tech lead. Tell me what's going on.

Book a call30 minutes, free