
Zweisprachiges Blog mit Astro i18n
Wie ich mein Blog auf Zweisprachigkeit umgebaut habe: Routing, UI-Texte und ein Sprachschalter ohne 404-Fehler.
Inhalt
Über die Jahre haben sich in diesem Blog Artikel auf Deutsch und (wenige) auf Englisch angesammelt. Beim Relaunch als “Static Site” vor ein paar Jahren hatte ich mich entschieden, auf englischen Content vorerst zu verzichten - bis ich kürzlich ein wenig Zeit hatte, mich mit den Internationalization-Features (i18n) von Astro zu beschäftigen.
In diesem Artikel beschreibe ich das Setup Schritt für Schritt: die URL-Struktur, UI-Texte, Verknüpfung von übersetzten Artikeln und nicht ganz triviale SEO-Einstellungen.
Anforderungen
- Deutsch ist die Hauptsprache und wird direkt via
/blog/...aufgerufen. Englische Artikel werden unter/en/blog/...ausgeliefert. - UI-Texte müssen ‘type-safe’ behandelt werden: d.h. eine fehlende Übersetzung verursacht einen Fehler beim Build-Vorgang.
- Artikel können in beiden Sprache veröffentlicht werden. Wenn eine Übersetzung existiert werden beide Artikel verlinkt. Es dürfen keine Links auf nicht existierende Seiten/Übersetzungen angezeigt werden.
- Es soll kein Javascript benötigt werden.
Routing
Astro bietet eingebautes i18n-Routing. Die nötige Konfiguration in astro.config.mjs is recht einfach:
export default defineConfig({
site: 'https://soltari.at',
i18n: {
locales: ['de', 'en'],
defaultLocale: 'de',
routing: {
prefixDefaultLocale: false,
},
},
});
Mit prefixDefaultLocale: false Bekommt die Hauptsprache kein Präfix. Astro erstellt keine zweisprachigen Seiten automatisch, aber es biete Hilfsfunktionen (z.B. getRelativeLocaleUrl()) und Astro.currentLocale. Routen werden in normales .astro-Dateien konfiguriert:
src/pages/blog/[slug].astro -> /blog/hallo-welt/
src/pages/en/blog/[slug].astro -> /en/blog/hello-world/
Um Codeduplizierung zu vermeiden sind die Routen nur simple Funktionen, um Pfade für jede Sprache zu generieren und an eine View zu übergeben:
---
import PostView from '../../../views/PostView.astro';
import { getPublishedPosts, postSlug } from '../../../lib/posts';
export const getStaticPaths = (async () => {
const posts = await getPublishedPosts('en');
return posts.map((post) => ({ params: { slug: postSlug(post) }, props: { post } }));
}) satisfies GetStaticPaths;
const { post } = Astro.props;
---
<PostView post={post} />
Die deutsprachige Route ist identisch, nur ohne 'de'. Die Sprache ergibt sich automatisch aus dem Unterverzeichnis, in welchem der Content (Artikel) angelegt wird.
UI-Texte
Alle Text für UI-Elemente (Buttons, Navigation) sind ein einfaches TypeScript-Objekt anstatt einer i18n-Bibliothek. Die Schlüssel werden im de-Objekt definiert und müssen im en-Objekt übereinstimmen:
const de = {
'nav.blog': 'Blog',
'post.readingTime': '{minutes} Min. Lesezeit',
// ...
};
const en: Record<keyof typeof de, string> = {
'nav.blog': 'Blog',
'post.readingTime': '{minutes} min read',
};
export const ui: Record<Locale, Record<UiKey, string>> = { de, en };
Wenn ich in de einen neuen Schlüssel anlege und vergesse, diesen auch in en hinzuzufügen, dann schlägt astro check fehl.
Zusätzlich nutze ich eine kleine Hilfsfunktion für Platzhalter:
export function useTranslations(locale: Locale) {
return (key: UiKey, params?: Record<string, string | number>): string => {
const text = ui[locale][key] ?? ui[defaultLocale][key];
return params
? text.replace(/\{(\w+)\}/g, (match, name) => String(params[name] ?? match))
: text;
};
}
In einer Component kann ich damit Platzhalter so einfach übersetzen: t('post.readingTime', { minutes: 3 }). Datumswerte behandle ich mit Intl.DateTimeFormat (de-AT oder en-GB) auf dieselbe Art und weise, um sie nicht manuell übersetzen zu müssen.
Mehrsprachige Artikel
Artikel werden als Markdown in src/content/blog/de/ und src/content/blog/en/ gespeichert. Die Sprache wird daher vom Unterverzeichnis bestimmt. Zwei Artikel werden verknüpft, wenn derselbe translationKey in ihrer Frontmatter konfiguriert wird. Die Artikel-URL (slug) kann sich dabei unterscheiden:
# blog/de/.../hallo-welt.md
translationKey: hello-world
# blog/en/.../hello-world.md
translationKey: hello-world
Mit getPostTranslations() werden, basierend am Übersetzungsschüssel (translationKey) eines jeden Artikels, Artikel in anderen Sprachen abgerufen. Der Build schlägt fehl, wenn ein Schlüssel innerhalb einer Sprache mehrfach vorkommt, um Copy&Paste-Fehler früh zu finden.
hreflang und der Sprachen-Umschalter
Hier wirds trickreich. Suchmaschinen erwarten <link rel="alternate" hreflang="...">-Tags, welche zu den Übersetzungen eines Artikels führen und der Sprachen-Umschalter soll immer zu demselben Artikel in der anderen Sprache führen.
Für Seiten wie “Über Mich” ist die Lösung noch recht einfach: jede Sprache bekommt ihren eigenen Pfad - /about/ <-> /en/about/. Alle anderen Seiten definiern eine explizite Liste an URLs, die existieren müssen:
- Artikel (Posts) übergeben die mittels
translationKeygefundenen Übersetzungen. - Jahr- und Tag-Seiten übergeben nur Übersetzungen, wenn das entsprechende Jahr bzw. Tag (incl. Seitennummer) existiert.
Die Layout-Komponente übergibt dieselben Informationen an die SEO-Komponente und den Sprachen-Umschalter. Dadurch können sich hreflang und der Sprachen-Umschalter nie unterscheiden. Wenn eine Übersetzung für eine bestimmte Seite nicht existiert ruft der Sprachen-Umschalte die Startseite der gewählten Sprache auf. Hreflang-Tags werden in diesem Fall gar nicht erst generiert.
Fallstricke
- Eine 404-Seite. Als Static Site können wir nur eine einzige
404.htmlkonfigurieren. Daher enthält meine 404-Seite die entsprechende Fehlermeldung auf Deutsch und Englisch. - Listen je Sprache: Das Inhaltsverzeichnis, Tag-Seiten und der RSS-Feed zeigen nur Inhalte in ihrer (einen) Sprache, den Inhalte mehrerer Sprache in derselben Liste anzuzeigen wäre sehr verwirrend.
- Tags sind sprach-spezifisch. Ein Tag, das nur auf Deutsch existiert darf keine englischsprachige “Tag”-Seite erstellen, da diese natürlich leer wäre.
Fazit
Die i18n-Unterstützung von Astro beschränkt sich mit Absicht auf Basisfunktionen. Es übernimmt die grundlegende Sprachkonfiguration und bietet Hilfsfunktionen für URLs und Pfade. Routen, UI-Texte und Übersetzungs-Links verbleiben in der Verantwortung des Entwicklers. Für kleine Projekte wie diesen Blog ist das ein Vorteil. Der gesamte Code besteht nur aus wenigen hundert Zeilen, ist typensicher und wird im Buildprozess vollständig geprüft.