Get in touch →
← All posts
Decisions2026.04.29

Every Page Is Its Own File

Under a section of this codebase’s CLAUDE.md literally titled “Pages ARE the page” sits a rule: a file in src/pages/ is not allowed to be a one-line re-export of a single component. No about.astro that exists only to import and render <AboutPage />. The route file itself has to contain BaseLayout plus the page’s actual markup, directly.

I want to write down why that rule exists, because it wasn’t a stylistic preference chosen in the abstract: it replaced a pattern that used to be all over this app, and the rule reads differently once you’ve seen what it was reacting to.

The anti-pattern it replaced

The old shape looked like this: about.astro imports AboutPage.astro and renders it, nothing else. index.astro imports HomePage.astro and renders it. roadmap.astro imports RoadmapPage.astro. Every route in the app was a thin pass-through, and the actual content (the layout, the markup, the logic) lived one file away, in a component that existed for exactly one reason: to be the thing the route file pointed at.

The stated cost is indirection: to understand any single route, you had to open two files, not one: the route file to confirm it really was just a pass-through, then the page component to see what actually rendered. That’s a small tax per route, but it’s a tax paid on every route, forever, for a benefit that the second half of the rule names directly: reuse that never happened. AboutPage.astro was never rendered from anywhere except about.astro. HomePage.astro was never reused anywhere except index.astro. The wrapper existed purely so the page file could stay short, and “stay short” isn’t a real requirement for a route file — a route file is supposed to be the thing you open to understand the route, and making it artificially short by shipping its actual content one hop away doesn’t make the route easier to understand, it makes it require two files.

What the rule actually asks for

The replacement isn’t “put everything in the route file no matter what.” It’s narrower: src/components/<area>/ is still the right place for a genuinely separate, nameable piece (a form, a card, a diagram), something that is its own concept regardless of which page happens to use it. The line the rule draws is reuse-or-name, not location. about.astro in this repo, for instance, pulls in PageHeader, AuthorStatement, and CtaBand as real components — each is a nameable thing (a page header, an author bio card, a call-to-action band) that could plausibly show up on more than one page, and CtaBand in particular is exactly the kind of component that gets reused across routes. What about.astro does not do is hand its own FAQ markup off to a FaqSection.astro that exists nowhere else: that markup sits directly in the route file, inline, because it isn’t a separate concept, it’s just what this page is.

The test, in other words, isn’t “is this markup more than a few lines,” it’s “would this thing make sense, and get used, somewhere else.” A form makes sense elsewhere. A card makes sense elsewhere. This page’s specific FAQ list, structured exactly for this page’s specific content, doesn’t.

The same instinct, one layer up: flat routes

There’s a second, closely related rule in the same file, right below the first: prefer src/pages/<name>.astro over src/pages/<name>/index.astro. A folder that contains nothing but an index.astro is described as the same anti-pattern, just at the routing layer instead of the component layer: it buys sub-pagination structure you’re not using, in exchange for a route that now needs a folder to express something a single file could express just as well.

The reservation carved out is specific: reach for a folder only when the route genuinely has children, a dynamic segment like log/[slug].astro, or real sub-pages that live under it. That reservation is doing real work in this codebase, not just a hedge. src/pages/log/ isn’t a flat file precisely because it isn’t flat conceptually: it holds [slug].astro, a dynamic route with as many actual pages as there are posts, which is exactly the kind of “real children” the exception is written for. Compare that to src/pages/about.astro or src/pages/roadmap.astro, both flat files sitting directly in src/pages/, because neither has children: about is one page, not a family of pages, and giving it a folder just to hold one index.astro would be paying the folder’s structural cost for nothing it returns.

It’s the same reasoning as “pages ARE the page,” aimed at a different axis. The component rule says: don’t create a second file (a page component) unless the thing in it is genuinely separate and reusable. The routing rule says: don’t create a second directory level unless the thing under it genuinely has more than one page. Both rules are refusals to add structure that exists only to make the current file shorter or the current path deeper, when nothing about the route or the content actually needs that extra layer yet.

The trade-off the rule accepts on purpose

The honest cost sits in the locale routing. Astro’s en/es setup means every route needs a physical file per locale: src/pages/about.astro and src/pages/es/about.astro both have to exist, as real, separate files. I read both in full while writing this piece, and they are, structurally, nearly identical: same imports, same getLocale/useTranslations calls, same component tree, same inline <style> block down to the media query breakpoint. The only substantive difference between them is which locale getLocale(Astro) resolves to and, as a consequence, which dictionary keys useTranslations(lang) pulls copy from: the English file reads t.title and gets English text, the Spanish file reads the exact same t.title accessor and gets Spanish text, because the translation lookup is what’s doing the localization work, not the route file’s markup.

That’s real duplication. Every future structural change to about.astro (a new section, a layout tweak, a new component pulled in) has to be made twice, once in each file, by hand, because there’s no shared “page body” component that both routes point at. If there were, this whole discussion would be moot: the “pages ARE the page” rule and the “every locale needs its own file” rule together mean the English and Spanish versions of a page are physically distinct files with physically duplicated structure, full stop.

The CLAUDE.md is explicit that this is accepted, not overlooked: “this is intentional, not duplication to ‘fix’ — each page stays self-contained, and the two files only ever differ in which dictionary key they read.” The alternative, a shared AboutPageBody.astro component parameterized by locale, imported into two thin route files, would eliminate the duplication and reintroduce exactly the anti-pattern the first rule exists to prevent: two files to open to understand one route, for a “reuse” that’s really just reuse between a page and its own translation, which was never the kind of reuse the rule was trying to enable in the first place.

What “genuinely separate and nameable” rules out in practice

The rule’s exception clause, pull something into src/components/<area>/ when it’s “a genuinely separate, nameable piece,” is easy to state and slightly harder to apply consistently, because almost anything can be given a name after the fact. The actual test I keep coming back to, looking at how about.astro is built, isn’t “can I think of a name for this” but “does a second, unrelated caller for this component plausibly exist.” CtaBand passes that test outright — it’s a call-to-action band that shows up wherever the site wants to end a page with the same nudge, and it doesn’t know or care which page rendered it. PageHeader passes it too, in the same way PostHeader does for log posts: a header shape reused across every page of a given kind, parameterized by props, genuinely agnostic to which specific page instantiated it.

What fails the test is anything shaped by one page’s specific content rather than by a reusable structural role. The FAQ list on about.astro is markup, not a component, precisely because a FaqSection extracted from it would take an array of questions and answers as a prop and render them, which sounds like a real component right up until you notice it would have exactly one caller, forever, because there’s exactly one FAQ page. A component with one caller isn’t wrong to have, but it’s not buying anything a well-organized block of inline JSX inside the route file wouldn’t already buy, and it does cost the same two-files-to-understand-one-route tax the original anti-pattern was named for. The line isn’t about complexity or line count — a ten-line inline block and a fifty-line inline block are treated the same way if neither has a second plausible caller; it’s specifically about whether the thing being considered for extraction has, or is reasonably expected to have, a life outside the one page currently rendering it.

Why this reads as a real trade rather than an oversight

It would be easy to look at about.astro and es/about.astro sitting there as near-mirror images of each other and assume nobody noticed, or that a shared base component is obviously coming later. Having read both files, that’s not what’s going on: the duplication is narrow and mechanical (imports, component tree, style block) and the part that isn’t duplicated (the copy) is factored out correctly, into the i18n dictionaries, which is the one place this codebase does enforce cross-locale consistency at the type level. The duplication that remains is exactly the shape the two stated rules predict it should be: routes stay self-contained per the first rule, and self-contained routes multiplied by two locales means two files with the same skeleton, per the second.

Whether that’s the right call for a bigger app with dozens of structurally-varying locale-specific routes is a fair question, and probably not one with a universal answer. For a site of this size, where every page’s structure is stable and the only real per-locale variable is copy, paying a small, mechanical duplication cost to avoid reintroducing a wrapper-component indirection that provided no actual reuse benefit reads, on inspection, like the right side of that trade.

The same trade shows up again, one layer down, in why this site builds to static output at all rather than rendering routes on demand — see Static-First for a Site That Never Needs to Be Fresh for the other half of the two-locale wrinkle: static generation is what turns “every route needs a file per locale” from a runtime redirect into a structural fact you can see directly in the file tree.

← All posts