Trilingual URL routing with Astro i18n slug mapping
12 min read

Astro i18n Without Plugins: Trilingual Websites with Slug Mapping

#Astro #i18n #Routing

When you need a multilingual website, the instinct is to reach for a plugin. Astro-i18next, Paraglide, astro-i18n-aut — options abound. But every plugin introduces dependencies, imposes folder structures, and does things you may not need. What if you want full control? What if you need translated URLs — /ueber-mich in German, /about in English, /sobre-mi in Spanish — with nothing opaque in between?

That is exactly what I built for this website: trilingual, using Astro’s built-in i18n routing and a single slug table. No plugin, no magic, fully transparent. In this tutorial, I walk you through every piece step by step.

Astro provides built-in i18n routing support that lets you serve content in multiple languages — with full control over URL patterns and fallback logic.

Astro Docs Official Documentation

The Problem with i18n Plugins

Internationalization plugins solve a real problem, but they often come with assumptions that do not fit every project. Some auto-generate folders, others require specific file naming conventions. Most do not translate URLs — you end up with /en/ueber-mich instead of /en/about. And when you need to debug an issue, you are staring at abstracted code you did not write.

Astro’s built-in i18n routing provides the foundation: locale-based prefixes, default locale detection, and language negotiation. What it does not provide is slug translation — and that is precisely where our custom solution comes in.

3
Languages
0
Plugins
1
Slug Table

Architecture Overview

The concept is straightforward: German is the default locale and gets no URL prefix. English gets /en/, Spanish gets /es/. Every page has a semantically meaningful slug in each language — no id parameters, no UUID paths, no language prefixes in front of German slugs.

Route resolution flow for i18n configuration
Astro Configuration

In astro.config.mjs you define the three locales and set prefixDefaultLocale to false. This means German has no prefix — /kontakt instead of /de/kontakt. English and Spanish automatically get /en/ and /es/ as prefixes. That is the only configuration Astro itself requires.

Slug mapping table with trilingual route assignments
Slug Table

A central TypeScript file (src/i18n/utils.ts) contains a table mapping every page path to its three language variants. /kontakt becomes /contact (EN) and /contacto (ES). A reverse lookup map enables resolution in both directions — from any slug to any target slug.

Language switcher with resolution logic
Helper Functions

Two core functions use the table: getLocalePath() generates locale-aware paths for navigation and links. getLocalePathFromCurrent() takes the current URL and generates the equivalent path in another language — perfect for the language switcher.

Translation JSON files with key mapping
Translation JSONs

Three JSON files (de.json, en.json, es.json) contain all UI text as flat key-value pairs. The function t(locale, key) returns the translated string, with automatic fallback to German. Simple, type-safe, and with zero runtime overhead.

The i18n Configuration in Astro

The starting point is astro.config.mjs. This is where you tell Astro which languages your site supports and how routing works.

// astro.config.mjs
import { defineConfig } from 'astro/config';

export default defineConfig({
  site: 'https://arnoldwender.com',
  output: 'static',

  // i18n — DE (default, no prefix), EN and ES prefixed
  i18n: {
    defaultLocale: 'de',
    locales: ['de', 'en', 'es'],
    routing: {
      prefixDefaultLocale: false,
    },
  },
});

With these three lines of configuration, Astro understands that:

  • Pages in src/pages/ belong to the German version (no prefix)
  • Pages in src/pages/en/ are the English version (prefix /en/)
  • Pages in src/pages/es/ are the Spanish version (prefix /es/)
  • Astro.currentLocale automatically provides the current language in every page

What Astro does not do: it does not translate slugs. /en/kontakt is a valid English route as far as Astro is concerned — but semantically nonsensical. This is where our custom work begins.

The Slug Table

The heart of the solution is a simple table mapping every path to its language variants. It lives in src/i18n/utils.ts and intentionally has zero dependencies.

// src/i18n/utils.ts — Trilingual slug mapping

const slugTable: { de: string; en: string; es: string }[] = [
  { de: '/kontakt',          en: '/contact',        es: '/contacto' },
  { de: '/ueber-mich',       en: '/about',          es: '/sobre-mi' },
  { de: '/preise',           en: '/pricing',        es: '/precios' },
  { de: '/datenschutz',      en: '/privacy',        es: '/privacidad' },
  { de: '/barrierefreiheit', en: '/accessibility',  es: '/accesibilidad' },
  { de: '/impressum',        en: '/impressum',      es: '/aviso-legal' },
  { de: '/blog',             en: '/blog',           es: '/blog' },
  { de: '/leistungen',       en: '/services',       es: '/servicios' },
  { de: '/faq',              en: '/faq',            es: '/faq' },
  { de: '/links',            en: '/links',          es: '/enlaces' },
  { de: '/stack',            en: '/stack',           es: '/stack' },
  { de: '/sitemap',          en: '/sitemap',        es: '/mapa-del-sitio' },
];
// Reverse lookup — any locale's slug → full entry for cross-locale resolution

const slugToEntry = new Map<string, { de: string; en: string; es: string }>();

for (const entry of slugTable) {
  slugToEntry.set(entry.de, entry);
  slugToEntry.set(entry.en, entry);
  slugToEntry.set(entry.es, entry);
}

// Example: slugToEntry.get('/contact')
// → { de: '/kontakt', en: '/contact', es: '/contacto' }
//
// slugToEntry.get('/contacto')
// → { de: '/kontakt', en: '/contact', es: '/contacto' }

The table is deliberately flat. Each entry is an object with three strings — no nested structures, no IDs, no metadata. This makes it easy to read, easy to extend, and easy to debug.

The slugToEntry map is the key trick: it registers every slug from every language as a key and points to the same entry. This way you can look up any slug — /contact (EN), /contacto (ES), or /kontakt (DE) — and find the entry to resolve the slug of any other language. Resolution works in every direction.

The getLocalePath() Helper

The first core function generates locale-aware paths. It is used in navigation, internal links, and anywhere you need a path for a specific language.

/**
 * Generate a locale-prefixed path with slug translation.
 * DE (default) gets no prefix and uses German slugs.
 * EN/ES get prefixed with locale code and use localized slugs.
 * Accepts slugs from ANY locale as input — resolves to target locale.
 */
export function getLocalePath(locale: string | undefined, path: string): string {
  const lang = (locale || 'de') as Locale;
  const entry = slugToEntry.get(path);

  if (lang === 'de') {
    // DE (default) — no prefix, return German slug
    return entry ? entry.de : path;
  }

  // EN/ES — prefix with locale code, use locale-specific slug
  const targetSlug = entry ? entry[lang] : path;
  return `/${lang}${targetSlug}`;
}
---
// In any Astro component
import { getLocalePath } from '../i18n/utils';

const locale = Astro.currentLocale || 'de';
---

<!-- Navigation with translated slugs -->
<nav>
  <a href={getLocalePath(locale, '/kontakt')}>
    {t(locale, 'nav.contact')}
  </a>
  <a href={getLocalePath(locale, '/ueber-mich')}>
    {t(locale, 'nav.about')}
  </a>
  <a href={getLocalePath(locale, '/leistungen')}>
    {t(locale, 'nav.services')}
  </a>
</nav>

<!-- Results by locale:
  DE: /kontakt, /ueber-mich, /leistungen
  EN: /en/contact, /en/about, /en/services
  ES: /es/contacto, /es/sobre-mi, /es/servicios
-->

The crucial point: you can pass the input path in any language. Whether you pass /kontakt, /contact, or /contacto — the function finds the entry and returns the correct slug for the target language. This makes navigation components language-agnostic: you write them once with German slugs, and the function translates automatically.

The Language Switcher

The language switcher is the piece that ties everything together. It needs to take the current URL, figure out which page the user is currently viewing, and generate the corresponding path in the target language.

/**
 * Normalize a pathname by stripping trailing slashes (except root /).
 * Ensures consistent slug lookup regardless of dev server behavior.
 */
function normalizePath(pathname: string): string {
  return pathname === '/' ? '/' : pathname.replace(/\/+$/, '');
}

/**
 * Translate a full pathname to the equivalent path in the target language.
 * Used by LanguageSwitcher to build correct cross-locale links.
 */
export function getLocalePathFromCurrent(
  targetLocale: string,
  currentPathname: string
): string {
  // Strip locale prefix and trailing slashes → clean base path
  const stripped = currentPathname.replace(/^\/(en|es)(\/|$)/, '/') || '/';
  const basePath = normalizePath(stripped);

  const entry = slugToEntry.get(basePath);
  const lang = targetLocale as Locale;

  if (lang === 'de') {
    // DE (default) — no prefix, return German slug
    return entry ? entry.de : basePath;
  }

  // EN/ES — prefix with locale code, use locale-specific slug
  const targetSlug = entry ? entry[lang] : basePath;
  return `/${lang}${targetSlug === '/' ? '' : targetSlug}` || `/${lang}`;
}
---
// LanguageSwitcher.astro (simplified)
import Icon from '../atoms/Icon.astro';
import { getLocalePathFromCurrent } from '../../i18n/utils';

const currentLocale = Astro.currentLocale || 'de';
const pathname = Astro.url.pathname;

const locales = [
  { code: 'de', label: 'Deutsch', flag: 'DE' },
  { code: 'en', label: 'English', flag: 'EN' },
  { code: 'es', label: 'Español', flag: 'ES' },
];

function getLocalizedPath(locale: string): string {
  return getLocalePathFromCurrent(locale, pathname);
}
---

<div class="lang-switcher">
  <button aria-expanded="false" aria-haspopup="listbox">
    <Icon name="globe" size={16} />
    <span>{currentLocale.toUpperCase()}</span>
  </button>

  <ul role="listbox" aria-label="Select language">
    {locales.map((locale) => (
      <li role="option" aria-selected={locale.code === currentLocale}>
        <a href={getLocalizedPath(locale.code)} hreflang={locale.code}>
          <span>{locale.flag}</span>
          <span>{locale.label}</span>
        </a>
      </li>
    ))}
  </ul>
</div>

The getLocalePathFromCurrent() function operates in three steps:

  1. Strip the prefix: The regex /^\/(en|es)(\/|$)/ removes /en/ or /es/ from the start of the URL. What remains is the raw slug — regardless of which language it currently represents.

  2. Look up the slug: The cleaned path is searched in the slugToEntry map. If /contact remains, the map finds the entry with all three language versions.

  3. Assemble the target path: For German there is no prefix, for EN/ES the locale code is prepended.

The Translation JSON Files

Besides slugs, we also need translated UI text: navigation labels, button text, form labels, footer links. These live in three JSON files with identical keys.

{
  "nav.home": "Startseite",
  "nav.about": "Über mich",
  "nav.contact": "Kontakt",
  "nav.services": "Leistungen",
  "nav.blog": "Blog",
  "nav.faq": "FAQ",
  "hero.cta.primary": "Kontakt aufnehmen",
  "hero.cta.secondary": "Portfolio ansehen",
  "footer.copyright": "Alle Rechte vorbehalten.",
  "cookie.message": "Nur essenzielle Cookies — kein Tracking.",
  "cookie.accept": "Akzeptieren"
}
{
  "nav.home": "Home",
  "nav.about": "About",
  "nav.contact": "Contact",
  "nav.services": "Services",
  "nav.blog": "Blog",
  "nav.faq": "FAQ",
  "hero.cta.primary": "Get in Touch",
  "hero.cta.secondary": "View Portfolio",
  "footer.copyright": "All rights reserved.",
  "cookie.message": "Only essential cookies — no tracking.",
  "cookie.accept": "Accept"
}
{
  "nav.home": "Inicio",
  "nav.about": "Sobre mí",
  "nav.contact": "Contacto",
  "nav.services": "Servicios",
  "nav.blog": "Blog",
  "nav.faq": "FAQ",
  "hero.cta.primary": "Contacto",
  "hero.cta.secondary": "Ver Portfolio",
  "footer.copyright": "Todos los derechos reservados.",
  "cookie.message": "Solo cookies esenciales — sin rastreo.",
  "cookie.accept": "Aceptar"
}

The t() helper is deliberately minimal:

import en from './locales/en.json';
import de from './locales/de.json';
import es from './locales/es.json';

type Locale = 'de' | 'en' | 'es';

const translations: Record<Locale, Record<string, string>> = { de, en, es };

/**
 * Get a translated string by key for the given locale.
 * Falls back to German (default) if the key is not found in the target locale.
 */
export function t(locale: string | undefined, key: string): string {
  const lang = (locale || 'de') as Locale;
  return translations[lang]?.[key] || translations.de[key] || key;
}

No nesting, no namespaces, no interpolation. A flat key-value object per language and a function that returns the value. The fallback to German ensures that missing translations never yield an empty string — you always see at least the German text, and if that is missing too, the key itself. Invaluable for debugging.

The File Structure

Here is an overview of how all the pieces fit together:

Architecture Overview

  1. astro.config.mjs

    Defines defaultLocale: "de", locales: ["de", "en", "es"], and prefixDefaultLocale: false. This is the entire framework configuration — three lines that tell Astro how routes map to languages.

  2. src/i18n/utils.ts

    Contains the slug table, the reverse lookup map, getLocalePath(), getLocalePathFromCurrent(), t(), and getTranslations(). A single file of ~110 lines that powers the entire i18n system.

  3. src/i18n/locales/*.json

    Three JSON files (de.json, en.json, es.json) with identical keys and language-specific values. They contain all UI text: navigation, buttons, forms, error messages, SEO metadata.

  4. src/pages/en/*.astro & src/pages/es/*.astro

    For every page there is an English and Spanish version under the respective locale folder. File names match the translated slugs: pages/en/about.astro becomes /en/about, pages/es/sobre-mi.astro becomes /es/sobre-mi.

  5. Components & Layouts

    Header, footer, and navigation use getLocalePath() and t() with Astro.currentLocale to render links and labels language-dependently. No duplication — one component for all three languages.

src/
├── i18n/
│   ├── utils.ts              ← Slug table + helper functions
│   └── locales/
│       ├── de.json            ← German UI text
│       ├── en.json            ← English UI text
│       └── es.json            ← Spanish UI text
├── pages/
│   ├── index.astro            ← DE Homepage (/)
│   ├── kontakt.astro          ← DE Contact (/kontakt)
│   ├── ueber-mich.astro       ← DE About (/ueber-mich)
│   ├── leistungen.astro       ← DE Services (/leistungen)
│   ├── en/
│   │   ├── index.astro        ← EN Homepage (/en/)
│   │   ├── contact.astro      ← EN Contact (/en/contact)
│   │   ├── about.astro        ← EN About (/en/about)
│   │   └── services.astro     ← EN Services (/en/services)
│   └── es/
│       ├── index.astro        ← ES Homepage (/es/)
│       ├── contacto.astro     ← ES Contact (/es/contacto)
│       ├── sobre-mi.astro     ← ES About (/es/sobre-mi)
│       └── servicios.astro    ← ES Services (/es/servicios)
├── components/
│   └── molecules/
│       └── LanguageSwitcher.astro  ← Uses getLocalePathFromCurrent()
└── layouts/
    └── BaseLayout.astro       ← Uses t() and getLocalePath()

Plugin-Based vs. Custom Solution

Aspect Plugin-Based Custom Slug Table Recommended
Bundle size Additional JS dependency Zero runtime overhead
URL control Plugin decides path format Full control over every slug
Flexibility Bound to plugin API Infinitely extensible
Setup effort npm install + configuration One file with ~110 lines
Slug translation Often unsupported Core feature
Debugging Understand plugin internals Your own code, full transparency
Updates Dependent on plugin maintainers No external dependencies
TypeScript Varies by plugin Native TypeScript integration

SEO Aspects: hreflang and Canonical

Translated URLs are only half the story. Search engines need to know which pages belong together. That is what hreflang tags in the <head> are for. For a deeper look at how structured data like BreadcrumbList and LocalBusiness schemas further enhance multilingual SEO, see my guide on JSON-LD structured data in Astro.

<!-- On the German page /kontakt -->
<link rel="alternate" hreflang="de" href="https://arnoldwender.com/kontakt" />
<link rel="alternate" hreflang="en" href="https://arnoldwender.com/en/contact" />
<link rel="alternate" hreflang="es" href="https://arnoldwender.com/es/contacto" />
<link rel="alternate" hreflang="x-default" href="https://arnoldwender.com/kontakt" />
<link rel="canonical" href="https://arnoldwender.com/kontakt" />
---
// In SEO.astro or BaseLayout.astro
import { getLocalePathFromCurrent, locales, defaultLocale } from '../i18n/utils';

const currentPath = Astro.url.pathname;
const siteUrl = 'https://arnoldwender.com';
---

{locales.map((locale) => (
  <link
    rel="alternate"
    hreflang={locale}
    href={`${siteUrl}${getLocalePathFromCurrent(locale, currentPath)}`}
  />
))}
<link
  rel="alternate"
  hreflang="x-default"
  href={`${siteUrl}${getLocalePathFromCurrent(defaultLocale, currentPath)}`}
/>

Every language version of a page references all other versions. The x-default tag points to the German version as the fallback. This tells Google that /kontakt, /en/contact, and /es/contacto are translations of the same page — and shows the user the correct version in search results.

Best Practices

Default Locale First

Always build the German (default) version completely first. The EN/ES versions are then copies with translated content and adjusted slugs. This way you have a working product before you internationalize.

Slug Consistency

Keep the slug table up to date at all times. Every new page needs an entry in all three languages. Make it routine: create page, update slug table, create files in en/ and es/.

Fallback Strategy

The t() function automatically falls back to German. But pages need explicit fallbacks: if an ES version is missing, redirect to DE. Never show a 404 just because a translation is missing.

SEO with hreflang

Every page must include hreflang tags for all language versions — including x-default. Remember the canonical URL: it always points to the current language version, not the default locale.

Content Collections

Blog posts live in locale-specific folders: blog/de/, blog/en/, blog/es/. Each post has a locale field in its frontmatter. This allows listings and tags to be filtered by language.

Type Safety

Define the Locale type explicitly: type Locale = "de" | "en" | "es". Use it everywhere instead of string. This way TypeScript catches typos in locale parameters at compile time.

Workflow for Adding a New Page

Create the German page

Create a new file in src/pages/, for example leistungen.astro. Set up layout, content, and SEO meta tags. Test.

Update the slug table

Add a new entry to slugTable: { de: "/leistungen", en: "/services", es: "/servicios" }. The reverse lookup map is updated automatically.

Create the English version

Create file at src/pages/en/services.astro. Match the file name to the English slug. Translate content, set locale to "en".

Create the Spanish version

Create file at src/pages/es/servicios.astro. Translate content to Spanish, set locale to "es". Test the language switcher — all three links must work.

Update navigation & hreflang

If the page appears in navigation, add translation keys to all three JSON files. hreflang tags are generated automatically when the SEO component uses getLocalePathFromCurrent().

Frequently Asked Questions

Frequently Asked Questions

Summary

Astro’s built-in i18n routing gives you the foundation: locale-based folders, automatic language detection, and clean URL prefixes. What it lacks — translated slugs, a bidirectional language switcher, and UI translations — can be added with a single TypeScript file and three JSON files. No plugin, no build overhead, full control.

The slug table is the centerpiece: it maps every path to its three language variants and enables cross-locale resolution in every direction. The helper functions build on top of it and deliver ready-to-use paths for navigation, links, and the language switcher. And the t() function ensures every UI text appears in the correct language.

The result: a trilingual website with clean, SEO-friendly URLs, a functional language switcher, and exactly zero additional dependencies. All in roughly 110 lines of TypeScript.