We’d been putting off migrating evilmartians.com off Gatsby for three years. Our website is how new clients find Evil Martians, and our way of serving technical articles to half a million developers. The migration always seemed important, but never enough for a busy consultancy to pull engineers from client work. Then, (and to our own surprise) in August 2026 we moved the whole thing to Astro in two weeks, with Claude. Here’s how we were able to afford the migration and the four bugs that would’ve shipped, had we let the agent run wild.

Legacy’s creeping costs

In 2021, Gatsby was a reasonable bet. It was a mainstream React static-site framework with a plugin for everything, backed by a company shipping hundreds of releases a year. We launched in September 2022 (which we wrote about at the time). In the years since, Gatsby’s pace slowed while the tooling around it kept moving. Our stack gradually stopped resembling what new frontend engineers learn.

By summer 2026, the site had grown to 367 blog posts and 28 page routes (around a thousand built HTML pages) with a GraphQL data layer sitting in front of a static site. Components pulled their data through a Gatsby-specific hook. Seventeen Gatsby plugins sat in package.json alongside the framework itself. Four of our dependencies needed hand-written patches to behave.

Nothing here was dramatic, but costs kept accumulating:

  • Every feature paid a tax. Adding a section to the site meant learning a framework-specific way of doing it before the work itself could start.
  • The pool of people who could safely touch it kept shrinking. New frontend engineers arrive fluent in current tooling. Ours was a dialect that stopped being taught.
  • Dependency drift compounds. Four patches means four places where an upstream fix can’t reach you.
  • Closing security advisories became our chore, not upstream’s. Running pnpm audit against our last Gatsby lockfile reported 19 of them, 13 rated high. Those that arrived via Gatsby’s own dependencies would properly close with an upstream release; in practice, closing one meant forcing a newer version through a pnpm override by hand. This meant one more patch on a dependency combo the framework wasn’t tested against.
  • A cold production build took 18 minutes. Nothing had broken; we’d outgrown the default. Gatsby generated every responsive image variant at build time, so build time tracked the size of the archive. Fixing a typo cost the same 18 minutes as a redesign.
Book a call

Irina Nazarova CEO at Evil Martians

Hire Evil Martians

Three years of migration postponement

We’re a consultancy, so client work comes first, and the site has never had a proper team. Instead, we had one engineer who knows it end-to-end, plus the occasional help. That arrangement is fine for shipping a landing page or a blog post. But it cannot absorb a framework migration since that’s really the kind of work that can’t be handed off half-finished to whoever’s free next.

So, it was never treated as a serious project since we agreed: we’d be pulling engineers off projects (potentially for months), and after the migration, the site would look exactly the same.

That’s a kind of trap, which isn’t unique to us. Naturally, the output of a successful migration is no visible change. So it’s the hardest possible thing to fund, which is why so many teams are still running production on frameworks they’d never choose today. If you’re a CTO reading this with a specific repo in mind, we feel for you.

It all came down to making the migration affordable.

What an LLM actually changed

Between the first Astro commit and the cutover, the diff came to 470 files, with 30,403 lines added and 32,061 removed. These are two separate piles of work here. The small pile is design: what replaces GraphQL, what hydrates in the browser, how content gets compiled. The very large pile is mechanical: moving the build from Webpack to Vite, replacing plugins, migrating page skeletons, restating the same behavior in a different framework’s idiom several hundred times without drift.

That large pile of mechanical work used to take months. But, luckily, this is what LLMs are good at, provided someone can state precisely what “the same behavior” means and test it. And with an LLM, the project shrank, from “a team spending 2-3 months engaged” to “the one engineer who already knows the codebase, spending a couple of weeks.AI made the migration cheaper.

Why Astro?

Migration required 3 big steps to prepare:

  1. Choosing a framework that would minimize migration risk
  2. Designing a strong migration plan
  3. Doing prep work to the existing codebase

Although we chose Astro for the minimal migration risk, its developer experience has been impressive.

React components stayed React components. Astro’s islands architecture let us keep the entire existing component library and change only what hydrates in the browser—a page could be Astro on the outside with the original component untouched inside. It’s also what turned the migration into a strangler fig run inside one repository: Astro grew around the live Gatsby site one group of routes at a time, both frameworks building from the same content, until cutover day was a one-commit flip instead of an all-at-once leap.

Content collections replaced the GraphQL layer with typed content plus plain async functions—where a page once declared a GraphQL query, it now just awaits the data it needs. The content pipeline itself didn’t have to change: LiquidJS is still in there, and so are JSX and our transformers, all running behind collections now instead of resolvers. We turned on strict schemas from day one, which immediately surfaced 18 content files with malformed YAML that the old lenient parser had been quietly accepting for years.

Fast is the default. An Astro page ships as static HTML with zero JavaScript unless a component explicitly opts into hydrating, and each island can load on its own trigger (when it scrolls into view, or when the browser goes idle) instead of everything arriving up front. On Gatsby, keeping JavaScript off a page was work; on Astro, putting it on a page is. Most of the homepage numbers at the end of this post come from that flipped default.

Astro is framework-agnostic. UI frameworks plug in as integrations—React, Svelte, Vue, Solid, even mixed on one page. We kept React, but React is no longer the foundation, just a set of components we could replace island by island if we ever wanted to, without another migration project.

Astro sends each page only the CSS it needs versus one stylesheet for the whole site on Gatsby. This means there’s less to download, but also, when two CSS rules disagree, the order decides what happens on the page. Our rules had been sitting in a certain order for years, which caused a bug we had to address. (See section Four bugs an autonomous agent would have shipped.)

The pre-planning

The migration plan was committed on May 25th: 1,119 lines, written ten weeks before any line of Astro code.

The plan is something of an audit of our own codebase, designed to answer one question before anything else got decided: how big is this project, really? The audit contained two sections that were particularly helpful:

  1. A running table of open questions and their resolutions. Every question that could have derailed the project went there the moment it came up and stayed until closed. Decisions lived in one reviewable place instead of in chat scrollback or someone’s head.
  2. a “Surprises from the audit” list. These were six ordering constraints and hidden couplings. Each of them would have broken a naive port, and none of them ever surfaced as a bug.

Now, let’s go over two headline findings that shrank the project dramatically.

The blog never depended on Gatsby. This site ran on Ruby on Rails from 2011 to 2021, and when we migrated off Rails we kept a decade of articles untouched, reimplementing their Liquid tags on the new stack. A bespoke template pipeline, twenty-two custom tags and compile work on every build increasingly felt like debt over the course of 5 years. The audit brought in a new perspective: post bodies compile to a plain string and the framework only ever touches the compile step. This made migrating 367 posts a breeze. Our content pipeline moved to Astro practically unchanged.

The GraphQL coupling was narrow. Instead of sprawling surface, our custom content tags needed about six operations from the data layer. “Drop GraphQL entirely” turned out to be a much smaller job than it sounded.

Neither of those findings appeared as a build error, or an LLM suggestion. If prompted to do a migration, the models wouldn’t proactively and thoroughly analyze your project, they would just start migrating the site. The context we were able to process during this preparation stage (1,119 lines of human decisions) made the model genuinely useful once the port began.

The months before we touched Astro

Before writing any Astro code, we needed to do some prep work.

Images moved first. The work took roughly a week. We took every image on the site from Gatsby’s build-time image pipeline (gatsby-plugin-image, gatsby-plugin-sharp, and gatsby-transformer-sharp all left in one PR) to imgproxy. Images stopped being processed at build time and started being processed on demand. We did this deliberately before the migration and inside the old framework. Images were the single largest Gatsby-coupled subsystem on the site, and doing that work under Gatsby meant we could verify it against a site that was known to work.

Image processing update produced the largest performance win of the whole project. Cold production builds dropped from 18 minutes to 4.

Addressing image optimization while still on Gatsby allowed us to avoid potential complexities of doing everything at the same and save on weeks of work during the migration itself.

If you take one practical thing from this post, take this: Invest in prep work as it often holds great wins.

In July a snapshot harness landed. We committed a deterministic, offline build that captures the complete rendered output of every representative route as a 24,802-line baseline. It’s one 264-line Node script. The build runs with the clock, the branch, the image server, and every external fetch pinned so that two runs of the same commit produce identical bytes, and then the script compares the output directory against the committed baseline. It answers this single question: is this byte-for-byte what it was before? And it answers it in seconds.

Snapshot harness was the highest-leverage thing we did. We argue it’s the precondition for LLM-assisted work on any system you care about. Generated code is fast to produce and expensive to review. Review transformed from reading every line into checking a diff of the actual output.

Then we decoupled, still on Gatsby. A May PR pulled our own code away from framework globals while Gatsby was still running the site, so everything that came later had a smaller blast radius.

The images, the harness, the decoupling—all of it shipped with the site looking identical the whole time. The two fast weeks of migration were only possible because of the slower work came first.

Two weeks, one merge at a time

DateWhat shipped
Aug 3Astro building alongside Gatsby, in the same repo
Aug 4Pages without content data
Aug 5Pages with content data: lists, cards, and filters
Aug 6The Markdown pipeline, compilation of articles
Aug 6Every remaining page, plus the feeds, sitemap, and robots.txt
Aug 10Headers, redirects, analytics, cookie consent
Aug 13Cutover: 365 files, +21,017 / −38,618

Look at the dates: August 3rd through 6th, then 10th through 13th. Monday to Thursday, twice. The migration was complete in 8 working days!

Two properties of that table matter more than its speed.

Both builds worked at every step but the last. Until cutover day, Gatsby was still building and serving the production site while Astro was built in parallel from the same content. Every day of those two weeks, abandoning the migration cost one revert. The hosting never moved either: the site runs on Netlify, and it stayed there the whole time; cutover changed which framework’s output Netlify deployed, nothing else.

The cutover commit alone deleted 17,601 more lines than it added. Deleting a framework deletes the code you wrote to accommodate it.

Some of that deletion had to be insisted on, because left alone, a model does the minimum faithful translation. Point it at a GraphQL query and it will rebuild that query on the new stack, in new syntax, faithfully preserving the layer of abstraction the plan had marked for deletion.

We caught the homepage’s copy still flowing through our content data layer and shipping inline with the HTML on every visit—a shape that only ever existed to feed Gatsby’s GraphQL, in a spot where a plain TypeScript import does the same job and gets cached by the browser. It built, passed and taxed the build and every page load. LLMs create a lot of code, and do little cleanup. We recognize this behavior because vibe-coded projects land on our desks regularly. The only reason the codebase came out of this migration smaller is because an engineer kept asking: does this layer still need to exist?

We recognize this pattern because vibe-coded projects regularly end up on our desks. The only reason the codebase came out of this migration smaller is because an engineer kept asking: does this layer still need to exist?

The cutover itself came with a 168-line manual QA checklist: every route, generated file, redirect, and third-party integration, walked through by a person on a preview deploy and then again on production.

The snapshot harness cannot do that part. It compares the bytes the build produces, and a page can be byte-perfect and still be dead on arrival: the HTML shows up, the JavaScript never wakes it up, and you get a filter that doesn’t filter, a “More” button that never reveals anything, a background video that never plays. We had already been bitten by exactly this mid-migration, on a batch of pages whose output was identical and whose interactive parts were inert. Someone has to open the page and click on things.

The same goes for everything that only exists in a live browser talking to a live third-party service: the cookie-consent banner, the analytics tags, the search index, the booking widget, the map on an event page. Automation checks coupled with human quality testing worked great.

Four bugs an autonomous agent would have shipped

Finally, here are 4 things AI didn’t do that well.

1. A stylesheet broke because the bundler changed. Our old bundler emitted CSS in dependency order. Astro splits CSS per page, which is better output and a different order. A number of our style rules had quietly depended on the old order for years. Nothing errors. The build is green, every test passes, and the satellite on the homepage comes out squished. We shipped a single site-wide stylesheet as a stopgap on cutover day, then wrote a tool that loads the built site in a headless browser at every breakpoint and reports every declaration whose winner depends on bundle order. It found 70 of them, across 19 style modules, including mobile and hover cases that a desktop-only look would never have surfaced. No LLM proposes that tool, since no failure ever asks for it.

2. The difference between “missing” and “empty” was jumbled. A field left blank and a field that was never on the form are different situations, and code treats them differently. Our components filled in a fallback whenever a value was missing; the new code wrote those values out as explicitly empty instead, and an explicit answer stops a fallback from firing. What you see is an author list that disappears from one type of page. There’s no error anywhere. It happened twice, and both times only the byte comparison against the old site caught it.

3. We had to reverse one decision after two days, and human familiarity with the code made that judgement. On August 10th we added client-side page transitions, the kind that make a static site feel like an app. On August 12th we took them back out. They made the site slower: every navigation re-fetched the full document and re-hydrated the entire page, so even the address bar lagged behind the click. No test caught that, and nobody measured it. It was obvious inside a minute of clicking around the preview, and obvious only to someone who already knew how the site was supposed to feel. Reverting was a judgment call about what this site is for: reading.

4. We had to deal with a crash that only existed in production. Fixing bug #1 properly is what surfaced this one. Once the cascade audit was clean, we shipped the follow-up that re-split our CSS into per-page files. Green on every check we had, including a full preview deploy. In production it took the site down with a hydration crash, and no preview could ever have caught it, because preview deploys go straight to Netlify while production sits behind a CloudFront cache.

Three systems interacted with none broken on their own. Netlify stamps a deploy identifier onto asset URLs so a half-updated page can’t mix old files with new ones (a guard doing exactly what it’s designed to do). CloudFront was caching those same assets for a year with a cache key that ignored the identifier. So, the browser requested two generations of the same file, received identical cached bytes under two different URLs, and ended up loading two copies of React into one page. Hydration died on the first component that called a hook.

Nothing in our repo was wrong. Rather, the bug lived between a CDN setting and a hosting feature, in infrastructure with no representation in the codebase. We rolled production back the same day, fixed the cache policy so that the deploy identifier counts as part of a file’s identity, and re-shipped the split CSS. It’s live. None of these were exotic, but all four were invisible to “does it build?” and “do the tests pass?

That fourth one deserves a closer look, though, because it’s a different category. The first three are mistakes with code, and you can at least imagine a sufficiently careful model catching them. The fourth lives in a cache policy nobody had looked at in years, interacting with a hosting feature enabled by default, and it only appears on the one deploy target you can’t test against.

What the migration gave us

Seventeen Gatsby plugins and four patched dependencies are gone; package.json is down from 108 to 86 direct dependencies. With them also went 15 pnpm audit advisories, 9 of them high. Since we moved image processing to imgproxy before the migration, build times remained around 4 minutes.

So, what does the homepage actually load? One methodology for both states of the site: headless Chromium loads /, nobody scrolls, hydration settles, and we count the decompressed same-origin bytes—a cost that doesn’t shrink on a fast connection, since it’s what the browser parses, compiles, and holds in memory.

(On the wire, Brotli compresses minified JavaScript roughly 3:1, so a network tab will show numbers about a third of these.)

The Gatsby column below represents the last Gatsby production deploy; “at cutover” is a fresh build of the state we shipped on August 13th; “today” is production as it stands after the follow-on work below. Prefetching for other routes is excluded on all three:

Gatsby (last production deploy)Astro at cutoverAstro today
HTML650.1 KB390.6 KB302.4 KB
JavaScript1,280.9 KB1,018.0 KB496.9 KB
Data JSON for the page itself18.4 KB
CSSinlined in the HTML158.2 KB155.6 KB
Fonts195.3 KB195.3 KB195.3 KB
SVG51.9 KB≈51.9 KB16.6 KB
Total≈2.1 MB≈1.8 MB≈1.1 MB

The data row is the GraphQL layer’s runtime shape: Gatsby hydrates from page data delivered as separate JSON, where Astro’s page already has its data in the HTML. The CSS row is the one apples-to-oranges cell: Gatsby inlined its styles, so their weight sits inside its 650.1 KB HTML figure, while Astro ships a separate stylesheet counted outside its HTML numbers.

Prefetching stayed out of the totals, but it’s where deleting the data layer shows up most starkly. Gatsby fetched page data for every link that so much as entered the viewport, on idle, before anyone touched the page—13.6 MB of JSON on our homepage, behind a metric nobody watches. The instant navigation it bought survived in a cheaper form: Astro prefetches too, but on hover, once a visitor looks like they’re about to open a page. Nothing is spent up front on pages nobody visits.

The third column is follow-on work, not the migration itself—quick changes once each section’s JavaScript became its own separate chunk. The homepage now hydrates per section instead of as one component. The hero comes up from a 9.2 KB chunk, and everything below the fold waits until you scroll to it.

And once JavaScript was attributable per section, the single largest thing loading on the homepage turned out to be the search dialog behind the header button: 460 KB of DocSearch, imported eagerly on every page of the site whether anyone opened it or not, sitting for years inside a whole-page bundle nobody had a reason to look into. Deferring it to first open took nearly all of that off every route.

All told, the homepage loads 61% less JavaScript than it did on Gatsby, and it moved the number people actually quote: the mobile Lighthouse performance score went from 66 to 90. That’s the property we were after: nothing loads until someone uses it.

Two Lighthouse reports side by side: the Gatsby homepage scores 66 for mobile performance, the Astro homepage scores 90.

Mobile Lighthouse performance on the homepage: 66 on the last Gatsby deploy, 90 today. Same components, different framework around them.

One caveat: that’s still far more JavaScript than a homepage should need, and trimming it further is the next thing on the list.

But another great outcome of this migration is a stack frontend engineers love and use: Vite, TypeScript, plain React components, and data as function calls instead of query dialects. This means pool of people who can safely change this site is finally growing. This now includes roughly anyone who knows modern frontend tooling. With bus factor no longer being an issue, we can run more experiments on the website.

That repo you just thought of…

AI has made brownfield work cheaper than it’s ever been. It’s just as difficult as before and still requires strong engineering talent and years of experience to complete, but it’s become much more accessible. We’d been putting off migration to Astro for years, only to see that it could now be completed by one person in just two weeks.

Most teams have a list like ours, sitting in the back of their minds for years. Now they finally have the tooling to check it off. Getting through it just takes an engineer who still knows the old code.

Our migration project began with images in January. Are you thinking of a particular repo now? Doing it is certainly cheaper than it was.

And if that repo is also on Gatsby, Astro’s migration guide has a Gatsby-specific page. Everything it covers is the part an LLM can do for you. And everything it doesn’t cover is probably something you can handle over a couple of weeks.

FAQ

How long does a Gatsby to Astro migration take?

For evilmartians.com (367 blog posts, 28 routes, a custom Markdown pipeline), the execution took two weeks, from the first Astro commit on August 3rd to the production cutover on August 13th, carried by one engineer working alongside Claude.

That figure is misleading on its own: it followed preparation stretching from January to July (moving images to imgproxy, building a snapshot-test harness, and writing a 1,119-line migration plan) during which the site stayed on Gatsby and looked unchanged. That preparation was never full-time work, so its calendar span says more about scheduling than about effort. For scale: our own by-hand estimate for the same job was two to three months.

Can an LLM migrate a production website on its own?

No, and the points of failure are worse than being slow.

In our migration, the four most damaging bugs (a CSS cascade that depended on bundle order, the gap between missing and empty values, a performance regression from client-side routing, and a production-only hydration crash caused by a CDN cache policy) all produced green builds and passing tests. Those are the only signals an autonomous agent has.

Every one was caught by an engineer who knew the codebase and knew what kind of wrong to look for.

Does Astro build faster than Gatsby?

For our site, the true answer separates two changes people tend to conflate. Cold production builds went from 18 minutes to 4, but that came from moving image processing out to imgproxy while still on Gatsby, months before Astro entered the repo. Image processing, not the framework, was most of our build time.

The framework swap itself left builds at around 4 minutes: the content pipeline came across unchanged, so the part of the build that takes the time didn’t change either.

What makes a codebase safe to hand to an LLM?

You need a mechanical way to prove the output didn’t change. We built a deterministic snapshot harness that captures the full rendered output of every representative route (a 24,802-line baseline) before starting. It reduces reviewing generated code from reading every line to checking an output diff.

Why migrate off Gatsby at all?

Gatsby still works, and it still ships: the latest release on npm is 5.16.1 from February 2026. Our reasons were about accumulated cost rather than any single failure: 17 Gatsby plugins plus the framework itself, 4 patched dependencies, a GraphQL layer a static site never needed, an 18-minute cold build, and a steadily shrinking pool of engineers fluent in the tooling.

Book a call

Irina Nazarova CEO at Evil Martians

Putting off a scary migration? Hire Evil Martians!