why I migrated from gatsby to astro?
August 17, 2026 • ☕️☕️☕️ 13 min read
Back in 2021, I wrote about why I chose Gatsby for my blog. Five years later, this site runs on Astro. Here’s why I switched, and everything that broke on the way.

State of Gatsby
Gatsby was acquired by Netlify in February 2023, and active development on the framework stopped shortly after. Gatsby Cloud got folded into Netlify Cloud that August, the core contributors moved on, and most plugin maintainers abandoned their plugins. The framework isn’t formally dead. npm install gatsby still works, patches still ship now and then, but the ecosystem around it is unmaintained, and the community discussion has gone in circles for two years.
For a nine-post blog you can ignore all that for a while. What pushed me over was small. A dependency bump surfaced a plugin issue that nobody was ever going to fix. I’d rather spend a weekend migrating than a weekend digging through abandoned plugin code.
Why Astro
I first heard about Astro from this Fireship video, around the same time I was picking Gatsby. What I liked then is still what I like now: the islands architecture. Pages ship zero JavaScript by default, and only the parts that need it get hydrated, using React, Vue or Svelte. The old blog shipped a React bundle on every page to render a list of links and a search box. That’s a lot of JavaScript for static content.
There were smaller reasons too. Astro loads markdown posts as content collections instead of through a data layer, with typed frontmatter and one folder per post. And GraphQL is on its way out. My day job is migrating away from it to good old REST APIs, and I wasn’t going to keep a GraphQL layer alive just to render markdown files.
Why now, and why Cloudflare
The hosting story goes back further than the framework story. I had already moved off GitHub Pages a while earlier. Back in college I did not have the money to buy a domain, and by the time I did I bought murugappan.dev on Cloudflare because they sell domains at cost with no renewal markup. When I tried to point it at GitHub Pages, I found out the apex-domain setup wants hardcoded IPv4 addresses in your DNS. After four years in B2B SaaS and an unreasonable amount of DR testing and reports, hardcoded IPs are a personal red flag, so the blog went to Cloudflare as well.
Then the ecosystem made the decision for me. Cloudflare acquired Astro in January 2026, then VoidZero, the company behind Vite, Rolldown, Oxc and Vitest, that June. PartyKit, which runs the chat on this site, joined back in 2024. The VoidZero announcement landed five days before I started the migration. The framework, its build tooling and the platform it runs on now belonged to one company. At that point I was 100% onboard. My CSP was backing the project, and that was definitely going to develop deployment synergies.
Two weeks later I upgraded to Astro 7, and Vite 8 and Rolldown came with it. Synergies indeed.
I was also confident because coding agents have made this kind of migration cheap. It’s the ideal problem for them. It’s mostly grunt work with nothing novel, and you can check every step against the site that already exists. Anthropic showed this at absurd scale when they rewrote Bun from Zig to Rust: 535,000 lines in 11 days for an estimated $165,000 in API tokens. Sure, the code the agents write might not be top notch, but at least it works.
The migration
I didn’t start by prompting “port my blog”. First I had the agent write a parity plan: everything the Gatsby site did, 24 features including image optimization and the offline service worker, each one mapped to its Astro equivalent. Then we ticked them off one at a time, with a build and a browser check after each. The plan is still in the git history if you want the full checklist. It’s a decent template for this kind of port.
The condensed version:
| Gatsby did it with | Astro does it with |
|---|---|
gatsby-source-filesystem + createFilePath for slugs | Content collections with a glob loader, slug = folder name |
| Draft posts hidden in production, visible in dev | A filter on the entry id in production |
React page + AllPosts for search, tags, reading time | React island at first; plain Astro components and ~30 lines of JS now |
gatsby-remark-images | Astro’s built-in image pipeline |
gatsby-remark-prismjs | markdown.syntaxHighlight: "prism", same PrismJS theme |
gatsby-remark-autolink-headers | rehype-slug + rehype-autolink-headings, same SVG |
gatsby-remark-smartypants | Astro’s built-in smartypants |
gatsby-browser + a webpack __name shim for mermaid | A dynamic import("mermaid") on pages that need it |
gatsby-ssr setPreBodyComponents for the pre-paint theme script | The same inline script, first thing in <body> |
Gatsby components + StaticImage for the bio and toggle | React islands with plain <img> tags |
react-helmet for the SEO head | BaseHead.astro |
gatsby-plugin-feed | @astrojs/rss + markdown-it and sanitize-html |
gatsby-plugin-sitemap | @astrojs/sitemap, one sitemap for the whole site |
gatsby-node onPostBuild for llms.txt | An llms.txt route |
gatsby-plugin-manifest | A static manifest.webmanifest |
gatsby-plugin-offline | Offline caching dropped; the manifest stays, so the site is still installable, and a self-destroying sw.js evicts the old worker |
gatsby-plugin-gtag | The gtag snippet inline in the head |
typeface-* font packages | @fontsource imports |
| GitHub Pages publish workflow | Cloudflare Pages, later Workers + static assets |
The blog moved into this repo next to the portfolio, one build command produced both sites, and 301s kept the old URLs alive. Halfway through I also folded both apps into a single Astro project, so now there’s one build, one deploy, one sitemap, and /blog comes from where a file sits instead of a base config. That part had its own bugs, covered below.
One more file before the bugs: sw.js. The old site used gatsby-plugin-offline, so anyone who had visited before had a service worker busy caching the Gatsby build. Skip the eviction and those visitors keep getting the old site forever, no matter what you deploy. The new sw.js is ten lines. It unregisters itself on activate and reloads any open tabs. It’s the easiest file in the migration to forget.
I got this wrong at first. The migration dropped the offline cache, not installability. The new site doesn’t register a service worker at all, so there’s no offline mode, but the manifest and icons are still there and you can still install it as an app. Chrome stopped requiring a service worker for installation a while back, so the manifest is what matters.
The bugs
The port itself took about a day. The next few weeks went into making it not embarrassing. This is the part I want written down, because “the agent did it” is only half the story. The agent can keep a build green and pass tests, but it can’t see the page, and a blog is a visual product. I caught every visual regression here by opening a page and staring at it.
The bug list, straight from the commit log (links go to the fixes):
| What broke | Why | Fix |
|---|---|---|
| Post tables lost all their styling | The rules lived in Gatsby’s stylesheet and never made it into the port | 7cca589 |
| Clicking an image in the react post landed on a 404 | Gatsby-era [](react.jpg) self-links. Astro hashes the <img> but the anchor stayed a raw relative path, and the zoom handler didn’t preventDefault | 383afad |
| Zoom grabbed the author avatar and the social icons | The selector was page-wide instead of scoped to the article | 006315d |
| Mermaid diagrams couldn’t be zoomed | The SVGs live behind blob URLs, and zoom only understood <img src> | c070a3e |
| Every image was broken in RSS readers | content:encoded kept post-relative src="react.jpg", and feed readers render the body detached from the post directory | cf2606b |
| The theme toggle disappeared, console full of React error #130 | react-toggle is CJS-only; moving the component under a "type": "module" package flipped the default import to the whole module.exports object, so React got an object where a component should be | 4c7fdc1 |
| Your theme choice reset when you crossed between the blog and the portfolio | The blog persisted a theme string, the portfolio an isDark JSON boolean | cb767e5 |
Every missing /blog/<url> showed the portfolio’s 404 | Astro only treats a top-level 404.astro as the status page, so the blog’s 404 never built to the path Cloudflare walks up to | 1085bbe, b525b45 |
| Half the redirects missed | Cloudflare Pages matches paths exactly, so /slug and /slug/ are two different rules | 0589222 |
| A formatter rewrote code inside six published posts | semi: true in the merged config formatted the JavaScript inside markdown fences | 135124a |
Local build green, CI red with MissingSharp | Astro’s image service needs sharp; my node_modules had cached optimized images and the build image had none | 3717f3e |
| The blog index still shipped React | AllPosts, TagBar and SearchBar were Gatsby leftovers | 2d5bee7 |
| Web fonts shifted the whole layout on load | No font-metric fallbacks | 967f0e7 |
Every git push died with exit 254 | The pre-push hook still ran npm --prefix blog, and blog/ didn’t exist anymore | 1085bbe |
| Returning visitors would have been stuck on the old site | gatsby-plugin-offline had registered a service worker at the old origin | the self-destroying sw.js |
Three of them are worth more detail.
The 404 that wasn’t. Astro only special-cases a top-level 404.astro, so the blog’s 404 page built to dist/blog/404/index.html, and Cloudflare’s not_found_handling walks up to the nearest 404.html, which didn’t exist under /blog/. Every missing blog URL served the portfolio’s 404 instead. There was no build error, no failing test and nothing in astro check. The file was there, just not where the platform looks. The fix emits dist/blog/404.html as well. Then I made the build fail if the copy doesn’t contain the blog’s title marker. That little assertion turned out to be the most useful thing this migration produced. The same regression came back a month later during the consolidation, and this time the build failed on it.
The vanishing theme toggle. The toggle rendered as a blank spot and the console threw React error #130. react-toggle is a CJS-only package, and its default export changes shape depending on the module type of the importing file. The component used to sit under blog/package.json, which had no type field, so the bundler gave us the class. Consolidating moved it under the root package.json with "type": "module", which switches on Node’s real ESM-from-CJS rules, and the default import became the whole module.exports, an object. React got {__esModule: true, default: Toggle} where a component should have been. The fix was one unwrap at the import site. Finding it was the expensive part.
The images that 404’d on click. Markdown from the Gatsby era wraps images in a link to the image file itself: [](react.jpg). Gatsby left that link alone. Astro optimizes the <img> into a hashed asset under _astro/, but the anchor’s href stayed a raw relative react.jpg, which resolves to /blog/react/react.jpg, a file that is never emitted. So clicking an image followed a dead link, because the zoom handler didn’t preventDefault. I dropped the wrapper links and made the handler bail out when an image sits inside an anchor, so the same markup in a future post zooms instead of navigating.
Everything else in that table went the same way. The checks were green and the page was wrong.
Where it landed
| Page | Mobile before the perf pass | Mobile after |
|---|---|---|
| Blog index | 98 | 100 |
| React post | 96 | 100 |
| PartyKit post | 96 | 99 |
Desktop is 100 everywhere. The index ships zero React now. The list, the tag chips and the search box are plain Astro components plus maybe thirty lines of inline JS. For reference, the 2021 post’s bragging right was 90+ on mobile, over a much simpler site.

If you’re just here to read, not much changed. Old post URLs 301 to /blog/<slug>/, with and without the trailing slash. The feed is at /blog/rss.xml. The posts themselves didn’t move. That was the point of the parity checklist. This was a migration, not a redesign.
Would I do it again?
Yes, no question. Agents are good at this kind of work. The destination is known, there’s a test suite to lean on, and the middle is grunt work. What they can’t do is look at the result. I found every visual bug in this post by opening a page and describing what looked off in plain English. The worst one, the 404, wasn’t visible at all until I checked the thing I least wanted to check.
I did hit a lot of visual bugs and had to verify and re-prompt a lot, but overall I am happy with the result, and I couldn’t have done this in this much time without AI.
References
- Netlify acquires Gatsby Inc., February 2023
- Netlify on the Gatsby Cloud evolution, August 2023
- “Is GatsbyJS officially dead?”, the long-running community thread
- The Astro Technology Company joins Cloudflare, January 2026
- VoidZero is joining Cloudflare, June 2026
- Cloudflare acquires PartyKit, April 2024
- Rewriting Bun in Rust, the scale of what agents can port
- Astro islands architecture, the actual docs
- Fireship on Astro, where I first heard about it
- This site’s source, the migration, bugs and fixes included