Nik McFly
ENRUKZISV
← Blog

Claude managed, Codex coded: two AIs migrated my production site while I answered six questions

A React SPA with a broken SEO layer became a self-hosted Next.js site in 36 hours, with Claude leading and Codex writing all 7,803 lines of code.

#ai#codex#claude#nextjs#seo

A React SPA with a silently broken SEO layer went to self-hosted Next.js in 36 hours and three production releases. Claude ran the audit and the reviews, ChatGPT Codex wrote all 7,803 lines of code, and my total contribution was six decisions and the word “go”, three times.


I run a small music site. React 19, Vite, client-side routing, originally generated by Lovable, later exported to GitHub and moved to my own VPS. On the morning of July 10 I asked an AI to run an independent architecture review of it. By the evening of July 11 the site was running on Next.js in production with 45 canonical URLs in Google, first-party analytics writing to Postgres, one-hop redirects from every legacy path, and three rollback files that never got used.

I didn’t write the code. I also didn’t write the plan, the acceptance tests, the Caddy configs, or the cutover procedures. Two AI systems did, checking each other’s work and occasionally proving each other wrong, while my job shrank to a role I can only describe as “the person who owns the irreversible decisions.”

The cast

Me. I made brand calls (which URL slug an EP gets, what happens to the splash page), approved production flips, and paid for the server.

Claude, as the lead. It ran the initial audit, wrote the migration plan, issued work orders, and independently verified the implementer’s claims against live production. It never wrote application code.

ChatGPT Codex, as the developer. It implemented everything: the Next.js app, the route registry, the acceptance suites, the Docker and Caddy changes, the staging environments. Twenty-seven commits, 292 tracked files, 7,803 physical lines of source.

The separation mattered more than I expected. Not because either AI was untrustworthy in the human sense, but because each caught things the other missed, in both directions. More on that below, including the score.

Claude’s audit found what nobody saw for months

The known problem: single-page apps serve an empty HTML shell, so crawlers without JavaScript see nothing. I expected the audit to confirm that and propose fixes.

Instead it loaded pages in a real browser, waited for React to finish, and read the DOM:

document.title
// "Generic Site Title"   <- same on every page
document.querySelectorAll('link[rel=canonical]').length
// 0

The site used react-helmet-async for per-page metadata. Under React 19 that library stops applying head tags and throws no error. Every page rendered its content correctly while its title, canonical, and structured data went nowhere. The failure had no visible symptom beyond a browser tab that never changed its label, and nobody stares at tab labels on their own site. Months of code review missed it because the code was correct; the library underneath it was dead.

The audit also read Google’s current documentation and came back with the fact that killed the cheap fix: the bot-detection prerender proxy I would have reached for is a pattern Google formally deprecated, and the AI crawlers I increasingly care about (GPTBot, ClaudeBot, PerplexityBot) don’t render JavaScript at all. No second chance at metadata. Full migration it is.

What a work order from one AI to another looks like

Claude broke the migration into releases and gave Codex scoped orders. The interesting parts weren’t the tasks, they were the fences:

  • A definition of done before any code. Five checks per page, the first being: metadata and primary content present in the initial HTML, verified by curl with no JavaScript. For song pages that meant the full lyrics, asserted line by line. Codex later named this the single most useful instruction it received, “the main thing that prevented a Next.js in name only, with client-side loading of lyrics and articles.”
  • A route registry as the single source of truth. One typed array of every canonical URL. The sitemap, robots.txt, llms.txt, redirect map, and acceptance suite all derive from it. The old sitemap had drifted into fiction precisely because someone maintained it by hand.
  • A parity counter that blocked the cutover. Every route started as pending and had to earn parity. Codex’s own tooling refused production until it read 25/25. Its early reports said things like “6 scaffold, 19 pending, 0 parity. Cutover impossible.”
  • Scope fences. Each release listed what it must not touch. An agent with momentum will otherwise improve things you didn’t ask it to improve.

Baseline, candidate, diff

For visual verification Codex produced three-panel comparisons for every page: the live production page on the left, the Next.js candidate in the middle, and a pixel diff on the right, at desktop and 375px widths. It committed 101 of these images to the repo across the project. Scrolling a wall of them and seeing near-black diff panels is the closest thing to a lie detector this migration had; layout drift shows up as glowing outlines you cannot argue with.

The functional side was even less negotiable. The HTTP acceptance script contains 72 static assertions which expand, across 45 routes, QR parameters, metadata, JSON-LD, OG images, and private surfaces, into 1,505 executed assertions per production run.

My favorite twelve lines of the whole project turn an architectural rule into something a build can fail on. The rule: every redirect has exactly one owner, either the reverse proxy or the framework, never both. The check:

const forbiddenCaddyTokens = [
  "redir /about ",
  "redir /home ",
  "redir /blog ",
  "path_regexp legacyPath",
  "redir /legacy-page ",
  "redir /old-campaign ",
];

for (const token of forbiddenCaddyTokens) {
  assert.equal(caddy.includes(token), false,
    `Path redirect still has two owners: ${token.trim()}`);
}

assert.equal(caddy.includes("/legacy-resolver/"), false,
  "Caddy must not own this route after the Next resolver ships");

A redirect defined in two places is a redirect that will eventually disagree with itself. Now the CI says so.

Where each AI was wrong, on the record

Codex’s failures were process failures. It wrote evidence reports to /tmp, a directory that evaporates on reboot, three separate times after being told to commit them. It attached empty screenshot sections to four consecutive reports while claiming visual parity was verified. It ran auth tests against the production database because production was the API that existed. Each got fixed by turning the request into a merge blocker, and its later reports listed every QA row it created in production, with record IDs.

Claude’s failures were confidence failures. It fetched the production sitemap, hit a stale cache, and reported a deployed fix as missing; Codex pushed back with content hashes and won. It asserted that a backend repository existed because a git remote pointed at it; the repository had never been created. It blamed a newsletter bug on Codex’s feature branch; Codex proved the branch never touched that code and found the real cause, Drizzle wrapping PostgreSQL’s duplicate-key error inside error.cause where the handler couldn’t see it.

Asked afterward where it had disagreed but stayed silent, Codex gave an answer I didn’t expect: it thought pixel-diffing every legal and login page was overkill (it was right), it didn’t believe a single Lighthouse run should be a hard gate (also right, though the gate caught real prefetch and image-loading problems), and it considered llms.txt experimental hygiene rather than a proven ranking factor (right again, and worth doing anyway at its cost of one generated file). The lesson I took: an implementer that executes instructions it privately doubts is a bug in the process, not a virtue. Ask for the doubts.

The worst moment was self-inflicted

Not DNS, not social-preview debuggers. During Phase 1C preparation the native Docker builds accumulated intermediate layers until the VPS disk hit 100%. PostgreSQL couldn’t write a checkpoint and entered a recovery loop, and the production API briefly answered 500. A staging exercise had caused a small, real production incident.

The response was textbook and fully Codex’s: stop the pending migration and the Caddy flip, delete only dangling build layers, let Postgres finish WAL recovery on its own without restarting the container or touching data files, then re-verify everything before proceeding. Free space went from zero to 15.79 GB, a weekly image-prune went into the ops plan, and every future build got a precondition: 15 GB free, checked before pulling images. The incident cost twenty minutes and produced the single most useful operational rule of the project.

Numbers

Metric Before After
Routes with unique metadata in initial HTML 0 45
Largest first-load JS 1.3 MB 658 KB
Mobile LCP (song page) unmeasured 1.8 s
Unknown paths returning a real 404 0% 100%
Sitemap URLs that actually exist 9 of 15 45 of 45
Executed acceptance assertions per release 0 1,505
Committed visual-diff images 0 101
Production flips / rollbacks needed 3 / 0
My lines of code 0 0

Wall-clock, from git timestamps: implementation blocks ran 25 to 55 minutes each, staging acceptance about 90 minutes, and each production cutover window about 15 minutes, of which the actual traffic flip took 302 milliseconds. The whole arc, first audit to third production release, fit in 36 hours.

What I actually did

Six decisions: confirm the target architecture, pick a URL slug for an EP, choose what happens to the splash page, decide the analytics stance, accept a content-debt tradeoff, and authorize three production flips. Every one was a business judgment or an authorization of something irreversible. None required me to open an editor.

That’s the division of labor I’d defend now: AIs own the reversible work and the verification, humans own the irreversible decisions and the standards. The standards are real work, and they came from the review layer: the definition of done, the scope fences, the artifacts-in-git rule, the rehearsed rollbacks. Without that layer I believe I’d have gotten a fast migration with quiet gaps instead of a fast migration with 1,505 receipts.

If you try this: give the implementing agent a registry to derive from instead of files to keep in sync, make it prove claims with committed artifacts, let it block its own cutover, ask it what it privately disagrees with, and keep one word for yourself.

Mine was “go”.