Webentwicklung

Einen typsicheren Blog mit Astro 5 bauen

Astro 5 hat eines der praktischsten Features geliefert, die ich in einem Meta-Framework gesehen habe: die Content Layer API. Sie ersetzt die alten Content Collections durch einen flexibleren, Loader-basierten Ansatz — und ist von Haus aus voll typsicher.

Eine Collection definieren

Content Collections beginnen mit einem Schema. Man definiert das Frontmatter-Format mit Zod, und Astro validiert jede Datei zur Build-Zeit:

// src/content/config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';

const blog = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
  schema: z.object({
    title: z.string(),
    date: z.date(),
    description: z.string(),
    category: z.enum(['devops', 'web', 'ai']),
    draft: z.boolean().optional().default(false),
  }),
});

export const collections = { blog };

Der glob-Loader scannt nach Markdown-Dateien und generiert typisierte Einträge. Wenn ein Post einen fehlenden Titel oder eine ungültige Kategorie hat, gibt es einen Build-Fehler — keine kaputte Seite in der Produktion.

Content abfragen

Posts abzurufen ist direkt:

import { getCollection } from 'astro:content';

const posts = await getCollection('blog');

// Volle Typ-Inferenz — post.data.title ist string, post.data.date ist Date
const published = posts
  .filter((post) => !post.data.draft)
  .sort((a, b) => b.data.date.getTime() - a.data.date.getTime());

Keine as-Casts oder manuelle Typ-Assertions nötig. Die Typen fließen direkt aus dem Zod-Schema.

Posts rendern

Das Rendering hat sich von entry.render() zu einer eigenständigen Funktion gewandelt:

import { render } from 'astro:content';

const { Content } = await render(post);

Dann im Template:

<div class="prose">
  <Content />
</div>

Mehrsprachiger Content

Ein Pattern, das ich nutze, ist die Organisation von Übersetzungen als Geschwister-Dateien in einem Ordner:

src/content/blog/
├── my-first-post/
│   ├── en.md
│   └── de.md
└── another-post/
    ├── en.md
    └── de.md

Der Glob-Loader gibt jedem Eintrag eine id wie my-first-post/en, die man parsen kann, um Slug und Sprache zu extrahieren:

function parsePostId(id: string) {
  const lastSlash = id.lastIndexOf('/');
  return {
    slug: id.slice(0, lastSlash),
    lang: id.slice(lastSlash + 1),
  };
}

Posts nach Sprache zu filtern wird dann zum Einzeiler:

const enPosts = posts.filter((p) => parsePostId(p.id).lang === 'en');

Warum das wichtig ist

Die alten Astro Content Collections funktionierten, hatten aber Schwächen bei der Typisierung und erforderten spezifische Verzeichniskonventionen. Die Content Layer API ist expliziter — man wählt den Loader, definiert das Schema, und die Typen folgen natürlich.

Kombiniert mit Astros eingebautem Shiki-Syntax-Highlighting und statischem Output bekommt man einen Blog, der schnell, typsicher und leicht zu pflegen ist. Keine Runtime, kein JavaScript, das für Content-Seiten an den Client ausgeliefert wird. Einfach HTML.