SDE Journey

why I migrated from gatsby to astro?

August 17, 2026 • ☕️☕️☕️ 13 min read

0:00

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.

The old Gatsby blog as archived in 2023, with a dark background, green monospace post titles, tag chips, a search bar and "why i chose gatsby for my blog?" in the list of posts

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.

Diagram 1

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 withAstro does it with
gatsby-source-filesystem + createFilePath for slugsContent collections with a glob loader, slug = folder name
Draft posts hidden in production, visible in devA filter on the entry id in production
React page + AllPosts for search, tags, reading timeReact island at first; plain Astro components and ~30 lines of JS now
gatsby-remark-imagesAstro’s built-in image pipeline
gatsby-remark-prismjsmarkdown.syntaxHighlight: "prism", same PrismJS theme
gatsby-remark-autolink-headersrehype-slug + rehype-autolink-headings, same SVG
gatsby-remark-smartypantsAstro’s built-in smartypants
gatsby-browser + a webpack __name shim for mermaidA dynamic import("mermaid") on pages that need it
gatsby-ssr setPreBodyComponents for the pre-paint theme scriptThe same inline script, first thing in <body>
Gatsby components + StaticImage for the bio and toggleReact islands with plain <img> tags
react-helmet for the SEO headBaseHead.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.txtAn llms.txt route
gatsby-plugin-manifestA static manifest.webmanifest
gatsby-plugin-offlineOffline caching dropped; the manifest stays, so the site is still installable, and a self-destroying sw.js evicts the old worker
gatsby-plugin-gtagThe gtag snippet inline in the head
typeface-* font packages@fontsource imports
GitHub Pages publish workflowCloudflare 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.

Diagram 2

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.

Diagram 3

The bug list, straight from the commit log (links go to the fixes):

What brokeWhyFix
Post tables lost all their stylingThe rules lived in Gatsby’s stylesheet and never made it into the port7cca589
Clicking an image in the react post landed on a 404Gatsby-era [![alt](react.jpg)](react.jpg) self-links. Astro hashes the <img> but the anchor stayed a raw relative path, and the zoom handler didn’t preventDefault383afad
Zoom grabbed the author avatar and the social iconsThe selector was page-wide instead of scoped to the article006315d
Mermaid diagrams couldn’t be zoomedThe SVGs live behind blob URLs, and zoom only understood <img src>c070a3e
Every image was broken in RSS readerscontent:encoded kept post-relative src="react.jpg", and feed readers render the body detached from the post directorycf2606b
The theme toggle disappeared, console full of React error #130react-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 be4c7fdc1
Your theme choice reset when you crossed between the blog and the portfolioThe blog persisted a theme string, the portfolio an isDark JSON booleancb767e5
Every missing /blog/<url> showed the portfolio’s 404Astro only treats a top-level 404.astro as the status page, so the blog’s 404 never built to the path Cloudflare walks up to1085bbe, b525b45
Half the redirects missedCloudflare Pages matches paths exactly, so /slug and /slug/ are two different rules0589222
A formatter rewrote code inside six published postssemi: true in the merged config formatted the JavaScript inside markdown fences135124a
Local build green, CI red with MissingSharpAstro’s image service needs sharp; my node_modules had cached optimized images and the build image had none3717f3e
The blog index still shipped ReactAllPosts, TagBar and SearchBar were Gatsby leftovers2d5bee7
Web fonts shifted the whole layout on loadNo font-metric fallbacks967f0e7
Every git push died with exit 254The pre-push hook still ran npm --prefix blog, and blog/ didn’t exist anymore1085bbe
Returning visitors would have been stuck on the old sitegatsby-plugin-offline had registered a service worker at the old originthe 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: [![alt](react.jpg)](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

PageMobile before the perf passMobile after
Blog index98100
React post96100
PartyKit post9699

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.

The blog today, running on Astro, with a starfield background, the same SDE Journey title, filter chips for the new tag vocabulary and the chat launcher in the corner

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


Profile picture

technical blog by Murugappan M
Hard-won lessons from building software that runs in production