← All Posts

Internationalization with Astro: i18n Without a Plugin

Astroi18n

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.