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

A mobile overflow bug on one blog post exposed a hand-rolled chassis—seven duplicated HTML pages—whose publishing workflow could not support a real cadence. The fix led to an Astro scaffold four hours later, every tracked migration phase closed by that evening, and a dependency bill accepted knowingly.

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

Key takeaways

  • A visible bug can be a report on the architecture: one page's mobile overflow was the first symptom of page chrome duplicated across seven hand-maintained HTML files.
  • The cheapest moment for a structural decision is while the diagnosis is still loaded: the Astro scaffold merged four hours after the responsive fix, and agents are what made that interval—and the structural option—affordable against the quick patch.
  • A static site generator buys a build-enforced content schema, generated OG images, RSS, and a sitemap, and charges a dependency chain that breaks on its own schedule; the trade only makes sense with an editorial cadence to serve.
  • Small, individually reviewable PRs are what make a one-day rewrite safe: ten of the eleven tracked phases shipped as eight PRs, the eleventh was the deploy itself, and reviewers blocked three of the eight before approving.

On April 8, 2026, the newest post on this site was broken for anyone reading it on a phone. A long URL pushed the layout wider than the viewport, code blocks escaped their boxes, text would not wrap, and the page slid offscreen behind a horizontal scrollbar. I filed an issue that morning: make the blog post pages responsive.

The bug mattered beyond one page. The site exists to be read—a portfolio with a blog I intended to publish regularly—and phones are where the reading happens. The sharper problem sat behind it: the publishing system could not support that cadence, and fixing the bug forced me to see it.

The chassis the bug lived in

The site was seven hand-maintained index.html files and one global style.css—a homepage, a blog index, four project case studies, and the post in question, each carrying its own pasted copy of the page chrome. I built it the way a product manager builds things: copy an HTML file, edit what changes, ship it. I am not an engineer, and the chassis shows both halves of that: I shipped something real without stopping to learn a framework, and I did not notice for weeks that what I had shipped could not grow. It took a bug to make me read every line of it.

PR #30 fixed the overflow: a 152-line diff across three files—the CSS, a spec, and a test. The CSS was modest: min-width: 0 on the grid children, overflow-wrap: break-word on prose, an overflow-x: hidden guard on the page container, and a new 480px breakpoint. A spec file and a Vitest test pinned the overflow-x: auto rule already protecting code blocks, so the invariant could not quietly regress.

The patch worked. It did not satisfy me. Adding a single new post required:

  1. Duplicate an entire HTML file.
  2. Hand-write the metadata (title, description, OG image path) into <meta> tags.
  3. Re-paste the head and footer chrome.
  4. Add the post to the blog index page, also by hand.
  5. Hope I had not introduced drift across seven near-identical headers.

Every step is a place for a typo, and the tax recurs on every post, forever. The visible symptom was “this page overflows on mobile.” The structural reality was that I had been hand-editing duplicated chrome on every page, and the responsive bug was just the first thing visible enough to admit it.

The decision, on the record

Four options were on the table that afternoon.

OptionPublishing a postChrome duplicationContent schemaNew moving parts
Ship the patch, change nothingFive hand steps, two files edited, drift risk across seven headersGrows with every pageConventions in my headNone
Keep hand-authoring, add disciplineSame steps, plus a checklistStill 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 is really two options. A traditional CMS renders pages at request time against its own data model: performance rides on the caching strategy and the origin’s uptime, and the schema is the vendor’s. A headless CMS feeding a static build sits in the same square as an SSG—build-time rendering, custom content models, cached delivery. 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 mine; for a one-person Markdown site an SSG gets both without a separate content service.

The performance claim deserves the same precision. This site is fast because of its deployment, not its category: Astro emits flat HTML into dist/, and Firebase Hosting serves it from its CDN under the Cache-Control headers set in firebase.json—an hour on HTML, JavaScript, and CSS, a day on the OG images, and nothing specified for anything else. An SSG with no cache configuration inherits none of that.

Two more criteria closed the decision. Cadence: only the bottom two rows remove the per-post tax, and I intended to keep publishing. Reversibility: an SSG’s output is the same flat HTML I had been writing by hand, so the deployable artifact stays portable and the old chassis stays one revert away—if the migration soured mid-flight, it could be backed out without taking the site down. The patch-only option was the most reversible and decided nothing; the SSG removed the tax while keeping the exit cheap. The open question was never “should I use one?” It was whether to start that afternoon or keep patching the hand-rolled site and do this in three months when it hurt more.

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 was not an accident. Diagnosing the overflow meant reading every line of the existing code, and reading every line is the moment you can no longer pretend the duplication is fine. The whole architecture was loaded in my head, sins included, and the cost of starting the migration would never be lower. Close the laptop, and tomorrow that context is gone.

Nothing in the repository records a framework bake-off—the first phase issue opens with Astro already chosen—so this is reasoning from memory, not a decision log. Eleventy would have worked. Hugo is fast, but its templating language was a steeper curve than a personal site justified. Next.js and Gatsby were overpowered: no React runtime needed on a site whose homepage is a static CSS Grid composition. What tipped it to Astro was the Content Collections API, which lets you define a Zod schema for your frontmatter and have the build fail if a post is missing a required field or has the wrong shape. That is exactly the discipline a hand-rolled site cannot enforce.

Eleven phases, eight pull requests

Before the scaffold even merged, the whole sequence was scoped: eleven phase issues, #35 through #45, opened as a batch in a five-minute window just before 2pm. Phase 0 installed Astro, pointed firebase.json at dist/ instead of the repo root, moved static assets into public/, and updated the documentation. Later phases ported one surface at a time—base layout, homepage, project pages (PR #56), blog layouts—each sized to be reviewed whole, deployed on its own, and reverted without touching its neighbors. Ten of the eleven phases shipped as eight PRs: two adjacent pairs—Mermaid support with figure captions, tests with cleanup—merged combined where splitting them bought no review value. The eleventh, Phase 10, was the production deploy itself, so it closed without a PR.

The reviews were not ceremony. Codex blocked three of the eight—#54, #62, and #63—with change requests before approving, taking three change-request rounds on #63; CodeRabbit commented on three. Phase 10, “Deploy and verify production,” closed at 6:39pm Pacific—the same Wednesday the bug was filed.

One caveat I owe the reader. Phase 10 carried a 25-item verification checklist: seven routes previewed, a Firebase emulator pass, visual diffs at three widths, OG and Rich Results checks, Lighthouse comparisons, a production smoke test. Not one box is ticked in the issue, and the issue carries no comments. The record proves the phase closed at 6:39pm, not that a verification ritual completed.

That leaves a hole in the timeline: the moment the site actually went live on Astro is not in the record at all. It plainly happened—the site runs on Astro today and every later phase of work builds on the Astro tree—but there is no deploy log, no smoke-test result, and no ticked checklist to date it. A migration case study that cannot evidence its own cutover is awkward to write, but the alternative is letting an issue-closure timestamp quietly stand in for a deployment.

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, 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 recordedThe site actually going live on Astro. No deploy log, smoke-test result, or ticked checklist survives for it. It plainly happened—every later phase of work builds on the Astro tree—but the date and time are not in the record
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 same-day claim has exact edges: the scaffold landed the same afternoon as the fix; all eleven tracked phases closed the same day; the polish and the SEO plumbing ran over the following week.

What the SSG bought

Type-safe frontmatter. The blog content config defines a Zod schema with required fields (title, description, date, tags, image, and category), optional fields (shortTitle, pullquotes, sidebar), and constrained enums (category must be one of the declared blog categories, and pullquote accent is red | yellow | blue; nothing else). If I write a post and forget the OG image, astro build fails with a line number and a field name. One fewer category of bug to remember to check.

Custom Remark and Rehype plugins. Mermaid diagrams and auto-numbered figure captions are both build-time Markdown processors. Mermaid graphs in the blog body are rendered to accessible inline SVG by rehype-mermaid; figure captions are auto-numbered by walking the AST. Neither was impossible in hand-rolled HTML; both are the kind of thing I would have put off forever.

Build-time OG image generation. Every post gets a 1200×630 social card, rendered by a Playwright integration that screenshots an Astro template at build time and strips the templates from the final output. It took real debugging to stabilize—that story is below—but every new post now inherits a consistent card without me touching Figma.

Sitemap, robots.txt, and RSS. The @astrojs/sitemap integration generates the sitemap from the routes Astro already knows about. A custom integration syncs the Sitemap: line in robots.txt so the filename never drifts. RSS is a small endpoint file under src/pages/rss.xml.ts. None of those existed on the hand-rolled site.

The outcome I care about is editorial, not technical. The post that started all this reads cleanly on a phone, and stays that way under a spec and two layers of tests rather than my memory. Publishing went from five hand steps across two files, carrying a drift risk that spanned seven duplicated headers, to one Markdown file and a build that rejects malformed frontmatter. And the metadata surfaces that should never drift—OG cards, RSS, the sitemap, and the Sitemap: line inside an otherwise hand-written robots.txt—behave consistently because they are generated, not maintained.

What it cost

I want to be honest about the bill.

A hand-rolled static site has roughly zero moving parts. This site now has Astro itself, its integrations, the Markdown processor, the Remark and Rehype plugin chain, the build-time OG renderer, the sitemap integration, the Firebase deploy step, and the Playwright responsive suite added in PR #70. Every one of those can break on its own schedule.

The OG integration bug is the canonical example, and the accurate telling beats the dramatic one. An early version computed the output directory with dir.pathname instead of fileURLToPath(dir). The two diverge in two ways. On Windows, dir.pathname yields /C:/path/to/dist, and joining og-templates onto that gives \C:\path\to\dist\og-templates—a path whose drive letter has become a directory name—the case the issue was filed for. On macOS and Linux they agree only while the path is free of characters a URL escapes: a checkout at /tmp/site checkout gives a pathname of /tmp/site%20checkout, which is not a real directory. Production impact was zero, because CI and my workstation are both plain-ASCII POSIX paths and no deploy ever surfaced it—but “zero impact” is a fact about the paths in use, not about the defect. The review system surfaced it: the nathanpayne-codex reviewer flagged it as a non-blocking observation during the external review of PR #171—the sibling robots.txt fix where the fileURLToPath pattern first landed—and it became issue #173, fixed in PR #174, then pinned in PR #175 with a contract test so no future integration reintroduces it. The hand-rolled site could not have had this bug, because it had no integrations. It also had no second reviewer catching a portability defect before any user saw it.

The same week brought a genuine production incident, a different bug: LinkedIn’s crawler was getting an empty page and stale OG images (issue #163). The fix chain—PR #170 through PR #172—closed a robots.txt sitemap 404 and added OG-target smoke checks. Two failures, two detection paths: one caught in review before it could matter, one caught in production because no check yet existed. Both belong on the bill.

The Astro v5 to v6.1 upgrade in PR #73 is the quieter line item. Major-version upgrades do not exist on a hand-rolled HTML site; on Astro they arrive on Astro’s schedule, and you make time whether or not you wanted to.

Whether this ledger nets out positive is a judgement, not a measurement—I have no cost baseline from April to compute a break-even against. The trade itself is clear: publishing friction went down, and a dependency chain that breaks on its own schedule came aboard. A site with a flat template and one post a year should refuse that trade. A site meant to carry an editorial cadence—strict frontmatter, OG cards, RSS, a homepage that does more than list links—should take it.

What the agents changed

A counterfactual runs under this whole story. The facts deserve separating from the estimates.

The facts: the tracked migration—scaffold through the last phase closing—ran inside one working day, across eight reviewed PRs. Claude Code did the authoring; every commit in those eight PRs carries its co-author trailer. Review came from three identities: nathanpayne-claude, nathanpayne-codex, and CodeRabbit. Cursor, a registered reviewer identity in this repo’s policy, reviewed none of it.

The estimate: had I done this by hand—learned Astro well enough to ship cleanly, ported the homepage, blog, and project pages, debugged the OG integration, written the Playwright suite, rewired the Firebase deploy, and propagated the documentation—my honest guess is somewhere between a long weekend and a vacation week of focused engineering time. That is an estimate of a migration that never ran; no manual baseline exists. But the decision was made against that estimate, and I do not have those weeks. Without agents, the likeliest world is the old chassis limping along with maybe two more posts in it before I stopped adding them.

What the agents changed, concretely, is the price of finding out. Trying the structural option cost the four stamped hours between PR #30 and PR #47—that is what “let me just see if this works” cost here—and a price that low is what let the migration compete with the patch on the same afternoon instead of being deferred to a hypothetical free week. The division of labor sits in the artifacts: the scoping is in issues #35–#45, the acceptance bar is in the specs and the two test suites, and the quality gate is in the review threads where three of the eight PRs were blocked until they changed. The agent wrote the code, ran the tests, and opened the PRs. The whole thing deploys to a Firebase project I pay for personally.

The PM lesson

When a user reports a visible bug, the default is to fix the bug the user reported. The user asked for the overflow to be gone, not for a new chassis, and most of the time shipping the patch and moving on is the whole job.

The exception is when the visible bug is a symptom of a structural ceiling—when the patch about to ship will be followed by more patches onto the same chassis, until some later one forces the admission that the ceiling was the problem all along. The responsive bug was that case. The patch was correct in isolation; shipping only the patch would have been correct triage. But diagnosing it meant reading every line of the old chassis, and after that, staying put would have been a deliberate choice rather than a default.

The hard part is not noticing the ceiling. It is that the smaller, faster, cheaper-looking option is already merged by mid-morning, and the structural option asks you to keep going anyway. The record shows the choice that got made: PR #30 and PR #47 sit four hours apart in the git log, with the phase issues for everything in between opened before the scaffold had even merged.