This blog is a static site built with Astro 7, put together over a single weekend, and this first post is the story of how it was built. Partly because writing it exercises the whole publishing pipeline, and partly because a few of the decisions (and failures) along the way seem worth sharing.
The short version: I wrote a phased plan, handed it to Claude Code as the project’s working instructions, and reviewed every diff before it was committed. The plan did most of the heavy lifting. The interesting parts are where reality pushed back.
Static, deliberately
The first decision was the easiest one: no SSR. A personal blog has no per-request state. Nothing changes between two visitors requesting the same article, so pre-rendered HTML is at least as good for SEO as server rendering, and it runs on cheap static hosting instead of a Node process someone has to keep alive.
There is exactly one place where SSR would be tempting: detecting the visitor’s language. A small inline script handles that instead. On the first visit, with no stored preference, it checks navigator.language, redirects / to /de/ once if the browser is set to German, and stores the choice. On every later visit it does nothing. No server needed, and it never overrides a choice the reader has made themselves.
Domain logic before UI
The plan enforced an order I would now recommend to anyone building even a small site: write all the non-visual logic first, as pure functions, with tests, before a single component exists. Slugs, date formatting, reading time, draft filtering, related posts, translation lookup, feed transformation. None of it imports anything from Astro:
/** Permalink path: EN unprefixed, DE under /de/. */
export function pathOf(post: BlogPostLike): string {
const prefix = post.data.lang === 'de' ? '/de' : '';
return `${prefix}/blog/${slugOf(post)}/`;
}
The functions work against a structural interface that describes what a content entry looks like, not against Astro’s own types. That made them trivially testable in plain Vitest, and it paid off immediately. The draft-filtering rule (“drafts are never built in production, anywhere”) is one tested function applied in every getStaticPaths(), in the feeds, and by extension in the sitemap. It is not a convention someone has to remember.
Two languages without an i18n framework
The site is bilingual: English unprefixed, German under /de/. It uses nothing but Astro’s built-in i18n routing and a typed dictionary. With only two locales, a full i18n framework is overhead. English is a plain object that defines the keys, German is a Partial of it, and a ten-line fallback function returns English for anything missing. The compiler knows every key, so a typo in a translation key is a build error instead of a silently English page.
One rule from the plan that I have come to appreciate: the language switcher always links to the translation of the current page, never to the other language’s homepage. If an article has no translation, the switcher disappears instead of dumping the reader somewhere unrelated.
Design tokens against the template look
The visual identity was specified before any CSS existed: six named colors per mode, two typefaces (IBM Plex Mono for display, IBM Plex Sans for body, self-hosted via Astro’s fonts API), and exactly one signature element. That element is the amber square-wave line under the header, a nod to the signal traces and load curves from the energy world I work in. Dark and light mode simply swap custom property values; there is no duplicated styling logic anywhere.
What actually went wrong
Astro’s CSP support versus Shiki. The plan had an optional item: enable Astro’s built-in Content-Security-Policy support. Tested in a real browser, it broke two things at once. Syntax highlighting lost every token color, because hash-based style-src does not cover inline style="" attributes, which is exactly how Shiki colors tokens. And one of the inline scripts (theme switching before first paint) was blocked. For a static blog with no user input, that trade is not worth it. Documented, reverted, moved on.
Trailing slashes. Internal links generated /blog/post while canonical URLs and hreflang alternates said /blog/post/. Both worked in the browser, which is precisely why this class of inconsistency survives until someone looks closely. One line in pathOf fixed it everywhere at once. That is the upside of routing every link through a single tested function.
A local DNS cache with a long memory. After wiring the domain through Cloudflare to Render, the site was live worldwide. Except on the machine that built it. macOS had cached the negative DNS answer from before the records existed. If you ever deploy a fresh domain and your own browser insists it does not exist while your phone happily loads it, try sudo dscacheutil -flushcache.
The workflow
The part of the setup I would keep above everything else is the pause discipline. After every phase: summarize, list the changed files, run the tests, stop, review the diff, and only then commit. An AI pair programmer is fast. The pauses are what kept every change reviewable and the architecture boring, in the best sense of the word.
Publishing this article is the final test of the pipeline: a Markdown file, a commit, a push, and Render rebuilds the site. If you are reading this, that worked.