article

How to Get Claude Code to Migrate an Astro Site to Next.js Cleanly

TL;DR Claude Code ported my whole Astro site to a static-export Next.js app (about 18,500 lines) — not hands-off, but most of what I had to tell it once became project rules, so the setup compounds and the next migration needs me less.

Claude Code rewrote about 18,500 lines of my Astro site into a static-export Next.js app. I wrote almost none of them. So the question worth answering isn’t “can an AI do a framework migration” — it did — but “how do you set it up so it goes cleanly, and what still needs you when it does.”

This is the sixth post in a series about redesigning a client’s site with Claude Code. You don’t need the earlier ones. The short version: I’d built a client’s marketing site in Astro, and the team taking it over runs Next.js. So the handoff wasn’t “here’s a zip” — it was “make this run in their stack.” I had the agent port the whole thing to a static-export Next/React app.

A framework migration turns out to be a good job to hand an agent: it’s large, mechanical, and every unit has a ground truth to check against. But “hand it over and wait” isn’t the shape of it. Even with a repo I’d deliberately prepared for handoff — a HANDOFF.md, a CLAUDE.md of project rules, a design-token system — the agent still needed four things only I could give it. The useful part, the part you can actually reuse, is that three of those four you can fold into that same setup so the next migration barely needs you. This post is that setup and the decisions behind it, plus the one thing that stays yours no matter what you document.

What the agent carried (the mechanical bulk)

Start with what the agent is genuinely good at here, because it’s most of the work by volume.

The conversion surface was large and repetitive. Astro’s 98 scoped <style> blocks become CSS Modules. Inline GSAP <script> blocks become React effects — a useScrollReveal hook, ScrollTrigger registered once, initial-hidden state set with gsap.set instead of CSS opacity: 0 (41 GSAP files, 47 vanilla scripts). getStaticPaths becomes generateStaticParams, and in Next 15 the route params is a Promise, so pages go async and await it (4 dynamic routes). 261 image imports get re-pathed. Design tokens are plain CSS variables, so they port as-is.

None of that needs judgment. It needs doing, correctly, a few hundred times. That’s the shape of a good agent job.

The structure I chose matters here, because the agent’s behavior followed from it. I ran it as an orchestrator with workers: the main loop on Opus 4.8 to read the codebase, make the calls, assemble the pages, and verify, and the bulk conversion fanned out to roughly 33 sub-agent runs, one per section-group, in parallel. I treated model choice as an engineering trade-off rather than something to leave to the agent: the workers ran on Sonnet to keep cost down, and I kept Opus only for five tab sections whose animation behavior had to match exactly. That split wasn’t obvious up front — I’ll come back to how I actually landed on it, because it’s one of the things you can’t know until you’ve run the job once. Across the four sessions it came to roughly 660K output tokens on the main loop, about 3.2M across the sub-agents, and on the order of 166M cache reads (a correction loop re-sends context every turn, so I’d treat that last number as approximate).

I made pixel-diffing the verification standard on purpose — a framework port is only safe to delegate if every unit has a ground truth, and “looks right to me” isn’t one. So each converted page got diffed against the original Astro render. The home page came out at 18,217px tall versus the original’s 18,247px — a 0.16% difference across an 18,000px page. That reference is what makes the mechanical bulk safe to hand off; without it, “the agent converted 31 sections” is a claim, not a check.

The four things the agent needed from me

So it wasn’t hands-off. Four times the agent needed something from me that wasn’t in the code. I want to be precise about which of these are genuinely un-document-able and which were just my project not saying something it could have — because that split is the reusable part, and I got it wrong twice before checking the logs.

What “compatible” means. This is the one I couldn’t have documented my way out of, and it’s worth sitting with. “Make it compatible” splits two ways — a real SSR Next app, or a React-shaped static export (output: 'export', no server). I’m the only one who knew the handoff wanted the static one: I’d talked to the receiving team, I knew they wire their own backend, so the forms stay action="#" and there’s no runtime. None of that is in the repo, because it’s a decision I’d made outside it.

I first assumed the agent asked because I hadn’t pointed it at the handoff doc. The logs say otherwise. It did read HANDOFF.md before it asked, and understood it — it summarized the repo back to me as “a static shell for handoff.” Then it still asked. Why? Because the doc describes the present (current state is static, zero backend calls, forms blank); the question was about future intent — now that it’s going to a Next.js team, should the port stay static or become a real app? That intent didn’t exist when HANDOFF.md was written and isn’t derivable from any file. The agent read the doc, got the present right, and correctly asked about the one thing no document could hold. When an agent asks you this, that’s it working, not a gap.

What’s actually shipped. The agent was about to port all 34 sections. It doesn’t know which routes are live versus dead test pages — that’s deployment knowledge. My line from the log was blunt: convert only the code the deployed site actually uses, think about the conversion cost. That cut 34 down to 31.

This one was preventable. The repo had a PAGES.md listing pages, but it didn’t mark which previews were dead or duplicate — that had to be inferred from Nav links in the code. A “live routes / dead pages” list would have let the agent prune its own scope. That’s documentable, and next time it will be.

What to spend. This is the split I promised to come back to. Left alone, the whole thing runs on Opus, and the cost climbs fast on a job with this much repeated context. Deciding what to spend was mine — the agent doesn’t cost-optimize itself — and I didn’t get it right by planning. I ran one section-group on Sonnet first and checked the output against the Opus version before trusting the rest to it. It held for the plain conversions; it did not hold for the tab sections, where the animation behavior drifted, so those stayed on Opus. That’s the actual origin of the “Sonnet for bulk, Opus for the tricky bits” rule — I found it by testing, not by deciding. I’d assumed cost just isn’t a project fact you can write down. But CLAUDE.md had deploy, design, and animation rules and no model rule, and there’s clearly room for one. The catch is you only know the split after running it once. So it’s preventable for the next project by banking the rule you just learned — a judgment call the first time, a documented default after.

A quality gate before fan-out. Before letting about 33 sub-agents convert in parallel, I asked for one to run first and be checked — a wrong pattern fanned out is a wrong pattern replicated 33 times. I’d have called this a habit, not a doc gap. But CLAUDE.md already had a check-before-you-act culture: it literally says to ask before starting the dev server and to show me the mapping table before touching code. What it didn’t have was a rule for mass parallel work: verify one conversion before you fan out. Add that one line and it’s covered — same shape as the model rule, a habit you turn into a documented default once you’ve hit the case.

So how much of this can you prevent?

Here’s the thing that surprised me looking back: most of those interruptions weren’t permanent. Once I understood why the agent had to stop and ask, the answer usually turned into a project rule — a line I’d add to CLAUDE.md or a doc so it never had to ask again. The migration didn’t just produce a Next.js app. It left behind a better-documented repo, one that would need me less next time. That’s the real payoff, and it’s worth naming: a good setup compounds.

Your migration won’t need my rules — a live-route list for a marketing site isn’t a line item in your app. What ports is the sorting, not the answers. Every time the agent stops to ask or overreaches, run the interruption through three questions, and you’ll know whether it’s something to document, something to learn once and then document, or something you should keep answering by hand:

  1. Is the answer a fact that exists but isn’t in the code? Then document it, and the agent stops asking. For me that was which routes are actually deployed — the agent was about to convert dead preview pages because “live vs. dead” lived in my head and the deploy config, not the repo. Your equivalent is whatever the agent can’t infer from the files but you already know: an env matrix, which feature flags are dead, which API is the real one.

  2. Is it a rule you can only write after running the job once? Then it’s a judgment call the first time and a CLAUDE.md line after. Mine were the model split (I tested one section-group on Sonnet, saw the animation-heavy ones drift, and only then knew “Sonnet for bulk, Opus for the tricky bits”) and the gate before fan-out (verify one conversion before you let 33 run). You can’t know these up front; you can make sure you only pay for them once.

  3. Is it genuinely future intent? Then keep answering it by hand — and know the agent asking is correct, not a gap. For me that was what “compatible” meant: stay static or go SSR. No document holds a decision you made in a conversation the repo never saw.

So the honest shape of “low-touch” is: set the project up — document the facts, bank the rules you learn — and the human load on the next migration collapses toward the one thing no document can hold, the future-intent call the agent is right to ask you about. You don’t get there by copying my CLAUDE.md. You get there by running your own interruptions through those three questions, the same way I had to.

The setup that makes it low-risk, not just low-touch

One more piece of setup, and it’s the one I’d never skip: the agent built the entire Next app into a fresh sibling folder — a separate directory next to the original — opening the original .astro files read-only for reference and never editing them. The original’s git history was never touched.

So a botched conversion couldn’t damage the thing being handed off. Worst case, you delete a folder. This is the same safety line as the rest of this series — don’t let the agent do the irreversible thing — applied to a whole-project transform: isolate the new, preserve the source. What you ship is the clean folder, not your working history.

It wasn’t all smooth, and it shouldn’t read like it was. Late in the work there was a consult modal whose chips kept wrapping to two lines when a scrollbar appeared, and the agent kept circling it — trying a CSS change, opening the browser tool to look, trying another, looking again. Three rounds in, I could see it was thrashing, and I already knew the answer was one no-wrap on a specific class. So I cut in: stop, do exactly what I tell you, don’t reach for the browser tool. It landed after that. That’s not the agent being bad at CSS; it’s that I’d built these pages and it hadn’t, so on a small fiddly fix my context beat its search. Real collaboration is uneven, and the uneven part is worth saying out loud — it’s the difference between this and an “AI did everything” story.

Where it landed

As of this writing the port itself is done — 30 routes, 70 section components, 4 templates, 4 dynamic routes — and full cross-page parity is the remaining polish. The first parity bug already surfaced and got fixed: a stray horizontal scrollbar at 1360px, traced to a decorative SVG sitting outside the content column. The git-to-GitLab handoff is still in progress; a large media history is making the first push heavier than it should be, which is its own story.

You can’t hand an agent a link and walk away, but a whole-project framework migration is a job it can carry — and how well it carries it is mostly a function of how you set the work up, not how good the model is that week. The agent did the 18,500 lines cleanly because the repo was documented, the run was structured as orchestrator-and-workers, the model split was tested rather than assumed, and every page had a pixel reference to check against.

But the biggest win wasn’t that Claude Code ported 18,500 lines. It was that most of what I had to tell it once became part of the project for good. I spent hours finding out that the deploy surface wasn’t documented, that the bulk could run on a cheaper model, that a fan-out needs a gate — and each of those cost me an interruption before it became a rule. You don’t have to spend those same hours. If you’re about to hand Claude Code a migration like this, don’t wait to discover the rules the hard way: as the agent works, run every interruption through the three questions above, and write the answers down as you go. The facts become docs, the lessons become CLAUDE.md lines, and what’s left is the one call the agent should ask you about anyway. That’s how a migration stops being an AI demo and turns into a repeatable engineering process — the setup, not the model, is the part worth keeping.

Researched and drafted by me and Claude, from my own project logs. Client, receiving team, and host details are anonymized.