Every route on this site (English, Spanish, the log posts, all of it) comes out of astro build as a folder of HTML files sitting next to their CSS and JS. No server process reads a request, decides what to render, and writes a response. The astro.config.mjs for this project doesn’t set an output mode at all, which is deliberate: static (output: "static", the default) is what happens when you don’t opt into anything else. I didn’t back into this by leaving a setting untouched; I looked at what the site actually needs to do and static was the answer that fit.
The config itself is short enough to read in full:
export default defineConfig({
site: "https://albgott.com",
prefetch: true,
i18n: {
defaultLocale: "en",
locales: ["en", "es"],
routing: {
prefixDefaultLocale: false,
redirectToDefaultLocale: false,
},
},
integrations: [
sitemap({
i18n: {
defaultLocale: "en",
locales: { en: "en-US", es: "es-ES" },
},
}),
mdx(),
],
});
Two integrations (sitemap and mdx) and an i18n block that says English is unprefixed (/about) and Spanish lives under its own prefix (/es/about), with no automatic redirect based on browser locale. Nothing here talks to a server runtime, a database adapter, or an edge function. That absence is the architecture.
What static actually removes
It’s easy to describe static output as “no server,” but the useful framing is narrower: there’s no server at request time. The distinction matters because plenty of the actual work (rendering MDX to HTML, resolving i18n dictionaries, building the sitemap) still happens. It just happens once, at build time, instead of once per visitor.
That single shift removes an entire category of operational surface:
- No runtime to keep alive. A Node process serving SSR pages can crash, leak memory, or need a restart after a dependency bump. A directory of HTML files can’t crash. There’s nothing running that could.
- No request-time attack surface for the app itself. Every route on this site is something a CDN or static host can serve straight from disk. There’s no application code parsing a client’s request, no server-side session state, no injection surface in a template that’s rendering per-user input — because there’s no per-user anything. The entire class of “SSR route handler had a bug” incidents doesn’t apply.
- Trivial caching. A static file’s cache key is just its path. No
Varyheader gymnastics, no cache invalidation tied to session or auth state, no worrying about whether a CDN edge cached a personalized response for the wrong user. Every visitor to/log/static-first-for-a-site-that-never-needs-to-be-freshgets the exact same bytes, so the CDN can hold those bytes indefinitely until the next deploy. - No patching treadmill for a runtime. There’s no Node version to keep current on a server, no framework-level SSR security advisory to track, because the thing serving requests in production is a plain HTTP file server (or a CDN edge), not an application runtime.
What it costs, honestly
None of this is free. Static output makes a specific bet, and the bet has a price:
Content changes require a rebuild and a redeploy. If I fix a typo in this post, that fix doesn’t exist anywhere until the CI pipeline runs astro build again and the new files land on the host. For a site with editorial content that’s fine — I control the publish cadence, and “rebuild takes a couple of minutes” is not a real cost when nothing here is time-critical. But it’s a real constraint, not a hypothetical one: there is no admin panel that edits a page and has it go live instantly.
No per-request personalization. Every visitor to a given URL gets identical HTML. There’s no “logged in as X, show their dashboard” rendering, because there’s no request-time code to check who’s asking. If this site ever needed a page that looked different depending on who was viewing it, static output couldn’t do that on its own — it would need client-side JavaScript to fetch and render the personalized part after the static shell loads: a different, and more limited, tool than SSR.
No request-time A/B logic or conditional routing. Something like “50% of visitors see variant A, 50% see variant B, decided per request” needs a decision made when the request arrives. Static hosting can’t make that decision — it can serve one fixed file per URL. Getting A/B testing on top of static output means either build-time variants at different URLs, or pushing the split into client-side JS after the page loads, which adds the exact runtime complexity static was chosen to avoid.
Build time scales with content, and eventually that’s felt. Right now the log has fifteen-plus posts across two locales and rebuilding is fast enough not to notice. That won’t hold at an arbitrary scale — a site with tens of thousands of pages would need incremental builds or a different rendering strategy. This site isn’t near that line, but it’s worth naming as the place where “just rebuild everything” stops being free.
Where this trade-off flips
I’d make a different choice for a different site, and it’s worth being specific about which one, rather than gesturing vaguely at “bigger sites need SSR.”
A site with real per-user state (a dashboard showing a specific account’s data, an app where what you see depends on who you are) needs request-time rendering or at minimum request-time data fetching. Static output has no mechanism for “render this differently because of who’s asking.”
A site with live pricing or inventory (the number on the page has to reflect what’s true right now, not what was true when the last build ran) needs either SSR or client-side fetching against a live API. Baking a price into a static HTML file is actively wrong the moment the price changes and the file hasn’t been rebuilt.
A site where content must update within seconds of a source event (a live sports score, a status page reflecting an incident in progress) can’t tolerate “wait for the next build.” That’s a real-time problem, and static generation solves a different problem: “this content changes occasionally and predictably, on a schedule I control.”
This site’s shape (an engineering log, a portfolio, static informational pages) sits nowhere near any of those lines. Nobody needs a personalized view of a blog post. There’s no price on this page that could go stale. When I publish a post, it’s fine that the live site reflects it a few minutes later rather than instantly. Every reason to reach for SSR is a reason that doesn’t apply here, and every cost of static (rebuild-to-publish, no personalization, no request-time logic) is a cost this site was never going to pay for anyway. That’s not a coincidence; it’s the argument for choosing static in the first place, worked backward from what the site needs rather than forward from what’s currently fashionable.
The two-locale wrinkle
The one place static output creates real friction here is internationalization, and it’s worth being specific about why, because “static and i18n don’t mix” isn’t quite the right lesson: the lesson is narrower. The i18n config says prefixDefaultLocale: false and redirectToDefaultLocale: false: English lives unprefixed at the root, Spanish lives under /es, and there’s no automatic redirect based on a visitor’s browser language. In an SSR setup, that last part could be handled per request: inspect Accept-Language, decide the locale, render accordingly, with no separate URLs needed for the decision itself. Static output can’t make that decision at request time, because there’s no request-time code running at all. The workaround is exactly what this site does, and exactly why every page in this app is its own self-contained route file rather than a thin wrapper around shared logic: two physical routes per page, src/pages/about.astro and src/pages/es/about.astro, each rendering its own dictionary at build time, with locale detection (if any) happening client-side after the static shell has already loaded.
That’s not a hidden cost, it’s the visible, structural price of the trade-off, and it shows up directly in the file tree rather than in some runtime behavior you’d only discover under load. Every route needs a file per locale. It’s more files to maintain, and it means “add a page” is really “add two pages,” which the project’s own conventions call out explicitly rather than trying to paper over with a code-generation layer. I’d rather see that cost up front in the repository structure than have it hidden behind a runtime redirect that only reveals itself when someone requests a locale the server wasn’t expecting.
Prefetching as the other half of the bet
The config also turns on prefetch: true, and it’s worth connecting that setting back to the static-first decision rather than treating it as an unrelated nicety. Prefetching works because the destination is a static file: Astro can fetch the next likely page in the background, the instant a link enters the viewport or gets hovered, precisely because fetching it early has no side effects and no server-side cost: it’s the same bytes any other visitor would get, served from the same CDN cache, whether or not the visitor ever clicks through. Try the same trick against an SSR route and you’re speculatively invoking server-side rendering for pages nobody may ever request, which is a real cost per speculative fetch, not a free one. Static output is what makes “guess ahead and fetch early” a strategy with no downside worth worrying about, and prefetching is a small, concrete example of a UX win that falls out of the architecture almost for free rather than needing its own justification.