wernicke IT
Astro Tailwind Web Development Performance

New website: why I chose Astro

If you build websites for clients, your own site is usually the last thing that gets attention. That was true here too. The old site worked, but it had accumulated more complexity than it needed. At some point you have to stop maintaining things you should have removed long ago.

The rebuild was a good opportunity to strip things back to what actually matters.

Why Astro

The requirements were straightforward: static output, no JavaScript framework, minimal dependencies, clean content management. Astro ticks all of those by default.

Astro ships no JavaScript unless you explicitly ask for it. What reaches the browser is HTML and CSS. Interactive components are only included where they are genuinely needed. For a site that primarily presents content, that is exactly the right model.

On top of that, the Content Layer API, stable since Astro 5, lets you define collections with a Zod schema and load Markdown files using the glob() loader. The result is fully typed access to frontmatter fields at build time, with no runtime overhead.

Tailwind v4

Tailwind v4 changes the setup significantly. There is no tailwind.config.js anymore. Configuration lives in the CSS file:

@import "tailwindcss";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:where(.dark, .dark *));

This sounds like less control, but it is actually the opposite. CSS Custom Properties replace the JavaScript configuration, theme variables are defined directly in the stylesheet, and @theme lets you extend the spacing and colour system. For anyone comfortable with CSS, this is far more intuitive than the JS config approach.

There is one downside: CSS language servers like the one built into Zed do not recognise @plugin or @custom-variant and flag them as invalid. This is an editor problem, not a code problem. A temporary workaround via .zed/settings.json:

{
  "lsp": {
    "vscode-css-language-server": {
      "settings": { "css": { "validate": false } }
    }
  }
}

Content Collections and i18n

The site is bilingual (German and English). Rather than using an i18n plugin, I took a pragmatic approach: one Markdown file per language in the pages collection.

src/content/pages/
  home-de.md
  home-en.md

Frontmatter fields hold all structured text (hero, section labels, contact copy), while the Markdown body contains the prose. Astro’s render() converts the body to HTML and <Content /> renders it inline. This cleanly separates structure from content without needing a full CMS.

Blog posts follow the same pattern. A lang field in the frontmatter controls which posts appear on /en/blog/ versus /blog/.

Dark mode without flash

Dark mode via prefers-color-scheme is convenient, but once you want to give users a manual toggle, you need your own solution. The classic problem: localStorage can only be read via JavaScript, and JavaScript runs after the initial render. Without precautions, the wrong theme flashes briefly on load.

A small inline script in the <head> prevents this by running synchronously before the browser renders anything:

<script is:inline>
  (function () {
    const t = localStorage.getItem('theme') || 'dark';
    if (t === 'dark') document.documentElement.classList.add('dark');
  })();
</script>

The script is intentionally minimal with no dependencies. It sets the .dark class on <html> before the first pixel is rendered.

One interesting consequence: SVG logos cannot be switched via prefers-color-scheme inside a <picture> element when using class-based dark mode, because the media query and the class are independent. The solution is two <img> tags with Tailwind utility classes:

<img src="/images/logo-light.svg" class="block dark:hidden" />
<img src="/images/logo-dark.svg"  class="hidden dark:block" aria-hidden="true" />

Simple, predictable, works.

Typography and fonts

The site uses Atkinson Hyperlegible Next. The typeface was designed specifically for readability and accessibility, distinguishing similar characters more clearly than most system fonts while still feeling natural. Self-hosted, no tracking, no Google Fonts.

The @tailwindcss/typography plugin handles prose styles for blog posts. Inline code gets a subtle teal-tinted background instead of the default backtick decoration the plugin adds:

.prose :where(code):not(:where(pre *)) {
  background-color: rgb(20 184 166 / 0.08);
  border-radius: 0.25rem;
  padding: 0.1em 0.35em;
}
.prose :where(code):not(:where(pre *))::before,
.prose :where(code):not(:where(pre *))::after {
  content: none;
}

TypeScript and Astro

One small but annoying stumbling block: the TypeScript language server cannot resolve astro:content if tsconfig.json does not explicitly include the .astro/ directory. Astro generates type declarations for collections there via astro sync. Without the entry, your include overrides the base config:

{
  "extends": "astro/tsconfigs/strict",
  "include": ["src", ".astro/types.d.ts"]
}

After the first astro dev or astro sync, all collection types are fully available, including typed frontmatter fields.

What is left

No cookies, no analytics, no JavaScript framework. A static HTML output that runs on a plain web server. Performance and privacy here are not the result of optimisation work. They follow naturally from deliberate decisions made at the start.

That is the right approach for a site of this size. And probably for most others too.


Questions about the setup, or interested in something similar for your own web presence? Feel free to get in touch.