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 — dark background, green monospace post titles, tag chips and a search bar, with "why i chose gatsby for my blog?" sitting 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 trickle out, but the ecosystem around it is unmaintained, and the community discussion has been going in circles for two years.

For a nine-post blog you can ignore all that for a while. What pushed me over wasn’t a dramatic break, it was a small one: a dependency bump that surfaced a plugin issue nobody was ever going to fix. I’d rather spend a weekend on a migration than a weekend on archaeology.

Why Astro

I first heard about Astro from this Fireship video, around the same time I was picking Gatsby. What I liked then is what I liked now: islands architecture. Zero JavaScript by default, hydrate only the parts that need it, use React (or Vue, or Svelte) for those parts when you actually need to. 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 machinery for content.

There were smaller reasons. Markdown posts as content collections instead of a data layer. Typed frontmatter, one folder per post, nothing in between. And GraphQL is on the death path. My day job is actively 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.

flowchart LR
    subgraph GATSBY["Gatsby"]
        MD["Markdown posts"] --> GQL["GraphQL data layer"]
        PLUG["Plugin ecosystem"] --> BUILD["gatsby build"]
        GQL --> BUILD
        BUILD --> REACT["Every page hydrates the full React bundle"]
    end
    subgraph ASTRO["Astro"]
        MD2["Markdown posts"] --> COLL["Content collections"]
        COLL --> BUILD2["astro build"]
        BUILD2 --> HTML["Static HTML, zero JS by default"]
        HTML -.->|only where needed| ISL["Islands: React, Vue, Svelte..."]
    end

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 — Vite, Rolldown, Oxc, 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 all runs on were suddenly under the same roof. 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 got Vite 8 + Rolldown as a direct side effect. Synergies indeed.

The other reason I was confident: this kind of migration has gotten cheap thanks to coding agents. It’s the ideal problem for them. Nothing novel, a lot of grunt work, and every step verifiable 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 from image optimization to the offline service worker, each one mapped to its Astro equivalent. Then we ticked them off one at a time, a build and a browser pass 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. Easiest file in the migration to forget.

Worth being precise about what got dropped, since I got it wrong myself at first: the offline cache, not the 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.

flowchart TD
    POSTS["content/blog/slug/index.md"] --> CC["Content collections"]
    CC --> BUILD["astro build"]
    BUILD --> DIST["dist/"]
    DIST --> CF["Cloudflare Worker + assets"]
    CF --> R["301s, cache + security headers, content-negotiated 404s"]
    CF --> API["/api, /mcp, /parties — Durable Objects"]

The bugs

The port itself took about a day. The next few weeks went into making it not embarrassing. This is the part I actually want written down, because “the agent did it” is only half the story. It can keep a build green and pass tests, but it can’t see, and a blog is a visual product. Every visual regression here was caught by me opening a page and staring at it.

flowchart LR
    P["Prompt: port this feature, keep the checks green"] --> A["Agent writes the Astro version"]
    A --> B["Build + tests pass"]
    B --> V["I open the page and stare at it"]
    V -->|something looks off| F["Describe it in plain English"]
    F --> A
    V -->|looks right| C["Commit"]

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 — /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 quietly served the portfolio’s 404 instead. No build error, no failing test, nothing in astro check. The file was there, just not where the platform looks. The fix emits dist/blog/404.html as well. And 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 stopped 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 was the same story in miniature: 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 — 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 whole point of the parity checklist: a migration, not a redesign.

Would I do it again?

Yes, no question. This is the shape of work agents are good at: 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, and the worst one, the 404, couldn’t be seen 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 very 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 actually runs in production