How Making a Page Responsive Led to a Full Astro Site Implementation

Fixing a mobile bug put me face to face with a blog generator that couldn't reproduce its own output. Four hours later, an Astro scaffold merged. I chose a build chain to make publishing practical, knowing it would bring maintenance work of its own.

Published
May 9, 2026
Updated
Author
Nathan Payne
Reading time
11 min read
Topics
EngineeringProductArchitectureAstroWeb Performance

Key takeaways

  • Fix the reported bug and stop, unless the bug is a symptom of a structural ceiling. The responsive patch had merged by mid-morning; it also made the ceiling impossible to unsee.
  • I chose an SSG while the architecture was still in my head. Agents made trying the structural option that same afternoon affordable; the manual alternative remains my estimate, not a measured comparison.
  • Publishing now gets schema checks, generated OG cards, RSS, and a sitemap. The price is a dependency chain and maintenance work. That trade serves the cadence I wanted.
  • Ten of eleven phases shipped through eight reviewed PRs, three of which Codex blocked before approval. All phases closed that day; the repository doesn't date the production cutover.

On April 8, 2026, the newest post on my site was broken on a phone. Screenshots at their native width pushed the content past the viewport. Text, headings, and code blocks were cut off at the right edge, with no way to scroll to them. That morning I filed an issue: make the blog post pages responsive.

I intended to publish regularly. A portfolio whose newest post broke on phones wasn’t doing its job. The publishing system had a separate problem: it couldn’t reproduce the HTML I’d already committed. By 10:47 that morning, I’d filed a ticket for that, too.

The old site

The site had seven index.html files and one global style.css. I maintained the homepage and four project case studies by hand. A 677-line Markdown generator I’d written produced the blog index and its one post. Every page carried a pasted copy of the chrome.

I built it as a product manager, not an engineer: copy an HTML file, change what I needed, and ship it. That got a real site up without first learning a framework. It took weeks to notice what adding to it would cost.

PR #30 fixed the overflow with a 152-line diff across three files: CSS, a spec, and a test. It added min-width: 0 to grid children, overflow-wrap: break-word to prose, overflow-x: hidden to the page container, and a 480px breakpoint. The spec and a Vitest test also pinned the existing overflow-x: auto rule for code blocks.

The patch worked. Adding a post still meant writing Markdown and running node scripts/generate-blog.js, except the generator couldn’t reproduce the hand-edits in the committed post and index. At 10:47am, forty-three minutes after the fix merged, I filed a ticket listing ten ways its output had drifted. A second post meant fixing the 677-line parser or hand-patching its output again.

Five pages of hand-edited chrome, two more generated by a parser I had already hand-patched around, and no way to add a post without touching all of it.

Choosing a publishing system

The recorded evaluation weighed two options, then compared five generators. The table is my own framing of the wider choice:

OptionPublishing a postChrome duplicationContent schemaNew moving parts
Ship the patch, change nothingRepair a drifted 677-line parser, or hand-patch its output, per postGrows with every pageConventions in my headNone
Patch the generator, add disciplineSame parser, more fixes per postStill one copy per pageStill informalNone
CMSAuthor in the CMSTemplated awayDepends on the CMSA content service—and, for request-time CMSs, a runtime in the hot path
Static site generatorWrite Markdown, commitTemplated awayMine, enforced at build timeA build chain and a dependency tree

The CMS row covers two different arrangements. A traditional CMS renders at request time against the vendor’s data model; performance depends on caching and the origin staying up. A headless CMS can feed a static build, just as an SSG does.

The axis that matters is not CMS versus static. It is when the templating runs and who owns the schema. I wanted build time and a schema I owned. For my one-person Markdown site, an SSG gave me both without a separate content service.

Publishing cadence made the bottom two rows worth considering: both removed work from every new post, and I intended to keep publishing. An SSG also left an exit. Its deployable output was the same flat HTML I’d been writing; the old site stayed one revert away. I needed to decide whether to start that afternoon or keep patching for three more months.

The same-day pivot

PR #30 merged at 10:04am Pacific. Four hours later, PR #47, “Phase 0: Scaffold Astro project,” merged at 2:11pm Pacific.

That timing wasn’t an accident. The fix merged at 10:04am, the generator-drift ticket went in at 10:47am, the evaluation report arrived at 12:46pm, and the eleven-phase plan at 1:13pm. I had the whole architecture in my head, sins included. Close the laptop, and tomorrow that context is gone.

The evaluation report compared Astro 6.0, Hugo 0.160, Eleventy 3.1, Next.js 16 in static export, and Zola 0.22 on eleven weighted criteria. Eleventy needed the smallest port. Hugo was fast, but Go templates meant learning another language for a personal site. Static export disabled the features that might justify Next.js’s dependencies.

Astro won on a high-weight criterion where Hugo and Eleventy scored nothing: the Content Collections API. I could define a Zod frontmatter schema and make the build fail on a missing field or wrong shape. That was the discipline I needed in place of my hand-rolled generator.

Eleven phases, eight pull requests

The 1:13pm migration plan became eleven phase issues, #35 through #45. They were opened together in a five-minute window before 2pm, before the scaffold merged.

Phase 0 installed Astro, changed firebase.json to serve dist/ instead of the repository root, copied static assets into public/, and updated the documentation. Later phases ported the base layout, homepage, project pages (PR #56), and blog layouts one surface at a time. Each could be reviewed whole, deployed separately, and reverted without touching its neighbors.

Ten phases shipped in eight PRs. Mermaid support and figure captions were combined, as were tests and cleanup; splitting those pairs wouldn’t have helped review. The eleventh, Phase 10, was the production deploy and closed without a PR.

Codex requested changes on three of the eight PRs—#54, #62, and #63—before approving. #63 took three change-request rounds. CodeRabbit commented on three. Phase 10, “Deploy and verify production,” closed at 6:39pm Pacific, the same Wednesday I filed the bug.

Phase 10 had a 25-item checklist: seven routes in preview, Firebase emulator testing, visual diffs at three widths, OG and Rich Results checks, Lighthouse comparisons, and a production smoke test. Every box is unticked, and the issue has no comments. Its 6:39pm closure doesn’t show that those checks happened.

The repository doesn’t date when the site went live on Astro. It happened—the site runs on Astro, and later work uses that tree—but deployment was a manual command without a workflow. There’s no smoke-test result or completed checklist to date it. Firebase’s own release history could. An issue closing is a poor substitute for a cutover timestamp, even in a migration I’d like to call a one-day success.

April 2026, Pacific timeMilestone
Apr 8, 7:54amIssue #28 filed: blog post overflows on mobile
Apr 8, 10:04amPR #30 merges the responsive fix
Apr 8, 10:47amIssue #33 filed: the blog generator can no longer reproduce the committed HTML
Apr 8, 12:46pmEvaluation report on #33: Astro over Hugo, Eleventy, Next.js, and Zola
Apr 8, 1:13pmMigration plan on #33: Phases 0–10
Apr 8, 1:50–1:55pmPhase issues #35–#45 opened as a batch
Apr 8, 2:11pmPR #47 merges the Phase 0 Astro scaffold
Apr 8, 6:39pmPhase 10, the last of the eleven, closes
Not in the repositoryThe site actually going live on Astro. No deploy workflow, smoke-test result, or ticked checklist dates it
Apr 8, 7:31pmPlaywright responsive suite merges (PR #70)
Apr 8, 7:54pmAstro v5 to v6.1 (PR #73)
Apr 9Blog template (PR #76) and index (PR #77) polish
Apr 14The LinkedIn crawler incident and the SEO plumbing chain it set off

The scaffold merged that afternoon, and all eleven tracked phases closed that day. The table also shows the later polish and SEO work over the following week. It doesn’t date the production cutover.

What it cost

The old HTML site had few moving parts. Now it has Astro and its integrations, a Markdown processor, Remark and Rehype plugins, the OG renderer, the sitemap integration, the Firebase deploy step, and the Playwright responsive suite added in PR #70. Each can break on its own schedule.

An early version of the OG integration used dir.pathname instead of fileURLToPath(dir) for the output directory. On Windows, dir.pathname gives /C:/path/to/dist. Joining og-templates produces \C:\path\to\dist\og-templates, treating the drive letter as a directory name. On macOS and Linux the functions agree only for paths without URL-escaped characters. A checkout at /tmp/site checkout gets a pathname of /tmp/site%20checkout, which isn’t a directory.

Production impact was zero. CI and my workstation used plain-ASCII POSIX paths, and no deployment exposed the defect. The review did: nathanpayne-codex flagged it as a non-blocking observation in the external review of PR #171, the robots.txt fix that first used fileURLToPath. That became issue #173, a fix in PR #174, and a contract test in PR #175. The old site couldn’t have had an integration bug. It also didn’t have a second reviewer catching a portability defect before a user saw it.

The same week, a different bug reached production. LinkedIn’s crawler got an empty page and stale OG images (issue #163). PR #170 through PR #172 fixed a robots.txt sitemap 404 and added OG-target smoke checks. Review caught the path bug before it affected anyone. Production exposed the crawler problem before a check existed. Both belong on the bill.

PR #73 upgraded Astro from v5 to v6.1. That’s another cost the old HTML site didn’t have. Major releases arrive on the framework’s schedule; I have to make time for them.

The time and the estimate

The recorded elapsed time and my estimate of the alternative are different things.

The tracked migration, from scaffold to the last phase closing, ran inside one working day across eight reviewed PRs. Claude Code authored it; every commit in those PRs has its co-author trailer. Review came from nathanpayne-claude, nathanpayne-codex, and CodeRabbit. Cursor was a registered reviewer in the policy but reviewed none of this work.

Doing it by hand would have meant learning enough Astro, porting the homepage, blog, and project pages, debugging OG generation, writing the Playwright suite, changing the Firebase deploy, and updating the documentation. My honest guess for doing this by hand was somewhere between a long weekend and a vacation week. I don’t have those weeks.

That’s an estimate of work I never did, with no manual baseline. It was still the estimate I made the decision against.

Agents made the structural option affordable to try that afternoon. PR #30 and PR #47 are four stamped hours apart. At that price, migrating could compete with the patch instead of waiting for a free week.

The work had a shape the agent could follow: the #33 plan and issues #35–#45 set the scope, the specs and two test suites set the acceptance bar, and reviewers blocked three of the eight PRs until they changed. The agent wrote the code, ran tests, and opened PRs. It all deploys to a Firebase project I pay for personally.

What I publish with now

Type-safe frontmatter. The content config uses Zod for required fields (title, description, date, tags, image, category), optional fields (shortTitle, pullquotes, sidebar), and enums. category must match a declared blog category; a pullquote’s accent must be red | yellow | blue. Forget the OG image and astro build fails with the file and field name. I don’t have to remember to check it.

Custom Remark and Rehype plugins. Build-time Markdown processors handle Mermaid and figure captions. rehype-mermaid renders accessible inline SVG in the blog body. An AST walk numbers the captions.

Build-time OG images. A Playwright integration screenshots an Astro template to make a 1200×630 card for every post, then removes the templates from the output. Stabilizing it took debugging. New posts now get a consistent card without a trip to Figma.

Sitemap, robots.txt, and RSS. @astrojs/sitemap generates the sitemap from Astro’s routes. A custom integration keeps the Sitemap: line in the otherwise hand-written robots.txt matched to its filename. RSS lives in src/pages/rss.xml.ts. The old site had a hand-maintained sitemap.xml and robots.txt, and no RSS.

The original post now reads on a phone, with a spec and two layers of tests keeping it there. Publishing means a Markdown file and a build that rejects bad frontmatter. I no longer have a 677-line parser to keep patching or seven copies of the header behind it.

The site is fast because of how it’s deployed, not because it’s an SSG. Astro writes flat HTML to dist/, and Firebase Hosting serves it through its CDN. firebase.json sets the Cache-Control headers: an hour on HTML, a day on OG images, and, since 2026-09-29, a year with immutable caching on the hashed /_astro/ CSS and JavaScript bundles. Nothing else gets a cache header. Choosing an SSG alone doesn’t give a site that configuration.

I don’t have an April cost baseline to calculate whether the trade breaks even. Publishing got easier, and I took on dependencies that need maintenance. For a flat template and one post a year, I’d keep the HTML. For the publishing cadence I wanted, with strict frontmatter, OG cards, RSS, and a homepage beyond a list of links, the build chain made sense.

Why I kept going

Usually, I should fix the bug someone reports and stop. Someone asking to read a post on a phone hasn’t asked for a new publishing system. The responsive patch answered that request.

Here, the bug was a symptom of a structural ceiling: seven pages of pasted chrome, two of them rendered by a generator I couldn’t trust to reproduce its own output. More posts would mean more repairs to that chassis. I filed the drift ticket forty-three minutes after the responsive fix merged; keeping the old site after that would have been a choice.

Shipping only the patch would have been correct triage. The patch is also what made the ceiling impossible to unsee.

The smaller option was already merged by mid-morning. I had to decide whether to keep going. I chose the build chain and its maintenance costs because I wanted to keep publishing. The patch made the post readable; the migration made another post practical.