Internationalization with Astro: i18n Without a Plugin
This article documents how internationalization is implemented in this portfolio. No i18n framework, no library, no plugin. Just Astro’s file-based routing, a TypeScript file with translations, and a few helper functions.
Routing: File Structure Instead of Configuration
Astro uses file-based routing. Every file in src/pages/ becomes a route. For i18n, this means: German pages live at the root, English ones under 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
German is the default locale without a prefix. English gets /en. No redirect, no middleware, no locale detection magic. Visiting / gives you German. Visiting /en gives you English. Predictable.
This also means: German routes are called /projekte and /kontakt, English ones /en/projects and /en/contact. No forced unification — each language uses the terms that feel natural for it.
Content Collections: de/ and en/
Blog posts and projects are MDX files in Content Collections. For internationalization, each collection has a subfolder per language:
src/content/
├── blog/
│ ├── de/
│ │ ├── hinter-den-kulissen.mdx
│ │ ├── ssh-brute-force.mdx
│ │ └── ...
│ └── en/
│ └── ...
├── projects/
│ ├── de/
│ │ ├── portfolio.mdx
│ │ └── ...
│ └── en/
│ └── ...
The slug structure contains the locale prefix: de/ssh-brute-force, en/ssh-brute-force. Helper functions filter by language and strip the prefix for the 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());
}
Not every German blog post needs an English counterpart. Content is only translated when it makes sense. The collections are intentionally independent of each other.
UI Strings: One File, No Library
For everything that is not content — navigation, buttons, labels, meta descriptions — there is a central translation file:
// 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;
}
The t() function takes the current locale and a key, returns the string. Falls back to German, then to the key itself. TypeScript ensures that invalid keys are caught at build time — TranslationKey is a union type of all defined keys.
In Astro components, it looks like this:
---
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>
No runtime overhead, no client-side JavaScript. Everything is resolved at build time.
The Language Switcher
The language switch is a simple link in the 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 computes the URL in the other language. From /blog to /en/blog, from /en/projects to /projekte. No JavaScript, no state, no cookie. A plain <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}`;
}
The active language is visually highlighted. That’s all it takes.
SEO: hreflang and Open Graph
Search engines need to know that /blog and /en/blog are the same thing in different languages. That’s what hreflang tags and Open Graph locale in the <head> are for:
---
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} />
Every page points to its counterpart in the other language. Google then shows the right version based on the user’s search language. og:locale ensures social media previews display the correct language.
The html tag also gets the correct lang attribute — important for screen readers and browser-native translation offers.
Helper Functions
Besides t() and getAlternateUrl, there are two small helpers:
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 extracts the language from the URL. localePath builds locale-aware links. Both are pure functions — no classes, no state, no framework.
Conclusion
Internationalization in Astro doesn’t need a plugin. File-based routing makes the content structure explicit, a central translation file with TypeScript types prevents typos, and hreflang tags handle SEO. The entire i18n layer consists of two files: translations.ts and utils.ts.
Of course, this means giving up a few things a dedicated library would provide — like automatic language detection or support for more complex text patterns. For a portfolio with two languages and manageable UI text, the custom approach works just fine. And if that ever changes, a library can be added without restructuring anything.
In the end, every page exists in the language that fits its audience — with minimal code and no dependencies.