← Alle Artikel

Mehrsprachigkeit mit Astro: i18n ohne Plugin

Astroi18n

Dieser Artikel dokumentiert, wie die Mehrsprachigkeit in diesem Portfolio umgesetzt ist. Kein i18n-Framework, keine Library, kein Plugin. Nur Astros dateibasiertes Routing, eine TypeScript-Datei mit Übersetzungen und ein paar Hilfsfunktionen.

Routing: Dateistruktur statt Konfiguration

Astro nutzt dateibasiertes Routing. Jede Datei in src/pages/ wird zu einer Route. Für i18n bedeutet das: Deutsche Seiten liegen im Root, englische unter en/.

src/pages/
├── index.astro              →  /
├── blog.astro               →  /blog
├── projekte.astro           →  /projekte
├── kontakt.astro            →  /kontakt
├── qr-generator.astro       →  /qr-generator
├── blog/[...slug].astro     →  /blog/:slug
├── projekte/[...slug].astro →  /projekte/:slug
└── en/
    ├── index.astro          →  /en
    ├── blog.astro           →  /en/blog
    ├── projects.astro       →  /en/projects
    ├── contact.astro        →  /en/contact
    ├── qr-generator.astro   →  /en/qr-generator
    ├── blog/[...slug].astro →  /en/blog/:slug
    └── projects/[...slug].astro → /en/projects/:slug

Deutsch ist die Default-Locale ohne Prefix. Englisch bekommt /en. Kein Redirect, kein Middleware, kein Locale-Detection-Magic. Wer / aufruft, bekommt Deutsch. Wer /en aufruft, bekommt Englisch. Vorhersehbar.

Das bedeutet auch: Deutsche Routen heißen /projekte und /kontakt, englische /en/projects und /en/contact. Keine erzwungene Vereinheitlichung, jede Sprache nutzt die Begriffe, die für sie natürlich sind.

Content Collections: de/ und en/

Blogposts und Projekte sind MDX-Dateien in Content Collections. Für die Mehrsprachigkeit gibt es pro Collection einen Unterordner pro Sprache:

src/content/
├── blog/
│   ├── de/
│   │   ├── hinter-den-kulissen.mdx
│   │   ├── ssh-brute-force.mdx
│   │   └── ...
│   └── en/
│       └── ...
├── projects/
│   ├── de/
│   │   ├── portfolio.mdx
│   │   └── ...
│   └── en/
│       └── ...

Die Slug-Struktur enthält das Locale-Prefix: de/ssh-brute-force, en/ssh-brute-force. Helper-Funktionen filtern nach Sprache und entfernen das Prefix für die URL:

export function getPublishedPosts(locale: Locale) {
  return posts
    .filter((p) => p.id.startsWith(`${locale}/`))
    .filter((p) => !p.data.draft)
    .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
}

Nicht jeder deutsche Blogpost braucht ein englisches Pendant. Content wird nur dann übersetzt, wenn es inhaltlich Sinn ergibt. Die Collections sind bewusst unabhängig voneinander.

UI-Strings: Eine Datei, keine Library

Für alles, was nicht Content ist — Navigation, Buttons, Labels, Meta-Beschreibungen — gibt es eine zentrale Übersetzungsdatei:

// src/i18n/translations.ts
export const defaultLocale = "de" as const;
export type Locale = "de" | "en";

const translations = {
  de: {
    "nav.home": "Home",
    "nav.projects": "Projekte",
    "nav.blog": "Blog",
    "nav.contact": "Kontakt",
    "hero.greeting": "Hi, ich bin",
    "blog.empty": "Hier entstehen bald Artikel.",
    // ...
  },
  en: {
    "nav.home": "Home",
    "nav.projects": "Projects",
    "nav.blog": "Blog",
    "nav.contact": "Contact",
    "hero.greeting": "Hi, I'm",
    "blog.empty": "Articles coming soon.",
    // ...
  },
} as const;

export function t(locale: Locale, key: TranslationKey): string {
  return translations[locale][key] ?? translations.de[key] ?? key;
}

Die t()-Funktion bekommt das aktuelle Locale und einen Key, gibt den String zurück. Fallback auf Deutsch, dann auf den Key selbst. TypeScript sorgt dafür, dass ungültige Keys zur Build-Zeit auffallen — TranslationKey ist ein Union-Type aller definierten Schlüssel.

In den Astro-Komponenten sieht das so aus:

---
import { t, getLocaleFromUrl } from "../i18n/utils";
const locale = getLocaleFromUrl(Astro.url);
---

<h2>{t(locale, "home.projects")}</h2>
<a href={localePath("/blog", locale)}>{t(locale, "blog.title")}</a>

Kein Runtime-Overhead, kein Client-JavaScript. Alles wird zum Build-Zeitpunkt aufgelöst.

Der Language Switcher

Der Sprachwechsel ist ein simpler Link in der Navigation:

<li class="lang-switch">
  <a href={getAlternateUrl(currentPath, "de")}
     class:list={[{ active: locale === "de" }]}
     aria-label="Deutsch">DE</a>
  <span class="lang-sep">|</span>
  <a href={getAlternateUrl(currentPath, "en")}
     class:list={[{ active: locale === "en" }]}
     aria-label="English">EN</a>
</li>

getAlternateUrl berechnet die URL in der anderen Sprache. Von /blog zu /en/blog, von /en/projects zu /projekte. Kein JavaScript, kein State, kein Cookie. Ein normaler <a>-Tag.

export function getAlternateUrl(
  currentPath: string,
  targetLocale: Locale
): string {
  if (targetLocale === defaultLocale) {
    return currentPath.replace(/^\/en(\/|$)/, "/") || "/";
  }
  const cleanPath = currentPath.replace(/^\/en(\/|$)/, "/");
  return `/en${cleanPath === "/" ? "" : cleanPath}`;
}

Die aktive Sprache wird visuell hervorgehoben. Mehr braucht es nicht.

SEO: hreflang und Open Graph

Suchmaschinen müssen wissen, dass /blog und /en/blog dasselbe in verschiedenen Sprachen sind. Dafür gibt es hreflang-Tags und Open Graph Locale im <head>:

---
const ogLocale = locale === "de" ? "de_DE" : "en_US";
const altLocale: Locale = locale === "de" ? "en" : "de";
const altUrl = new URL(
  getAlternateUrl(currentPath, altLocale),
  Astro.site
).href;
---

<link rel="canonical" href={canonicalUrl} />
<link rel="alternate" hreflang={altLocale} href={altUrl} />
<link rel="alternate" hreflang={locale} href={canonicalUrl} />
<meta property="og:locale" content={ogLocale} />

Jede Seite verweist auf ihre Entsprechung in der anderen Sprache. Google zeigt dann die richtige Version basierend auf der Suchsprache des Nutzers. og:locale sorgt dafür, dass Social-Media-Previews die korrekte Sprache anzeigen.

Das html-Tag bekommt ebenfalls das richtige lang-Attribut — wichtig für Screenreader und Browser-interne Übersetzungsangebote.

Hilfsfunktionen

Neben t() und getAlternateUrl gibt es noch zwei kleine Helfer:

export function getLocaleFromUrl(url: URL): Locale {
  const [, segment] = url.pathname.split("/");
  if (segment === "en") return "en";
  return defaultLocale;
}

export function localePath(path: string, locale: Locale): string {
  const clean = path.startsWith("/") ? path : `/${path}`;
  if (locale === defaultLocale) return clean;
  return `/${locale}${clean}`;
}

getLocaleFromUrl extrahiert die Sprache aus der URL. localePath baut locale-aware Links. Beide sind reine Funktionen, keine Klassen, kein State, kein Framework.

Fazit

Mehrsprachigkeit in Astro braucht kein Plugin. Das dateibasierte Routing macht die Content-Struktur explizit, eine zentrale Übersetzungsdatei mit TypeScript-Types verhindert Tippfehler, und hreflang-Tags erledigen das SEO. Der gesamte i18n-Layer besteht aus zwei Dateien: translations.ts und utils.ts.

Natürlich fehlen damit ein paar Dinge, die eine fertige Library mitbringen würde — etwa automatische Spracherkennung oder Unterstützung für komplexere Textbausteine. Für ein Portfolio mit zwei Sprachen und überschaubarem UI-Text reicht der eigene Ansatz aber völlig aus. Und falls sich das mal ändert, lässt sich eine Library nachrüsten, ohne die Struktur umzubauen.

Am Ende steht jede Seite in der Sprache da, die für ihr Publikum passt — mit wenig Code und ohne Abhängigkeiten.