perkun.eu Services Portfolio Blog About Contact PL
← Blog

4/7/2026

Astro and i18n — full bilingualism without headaches

TL;DR: Astro 5 has built-in i18n routing (/pl/, /en/). Translations as TypeScript const in src/i18n/. hreflang in <head>. Markdown blog in two languages via file prefix.

Bilingualism in static sites is a topic that appears simple on the surface but has dozens of edge cases in practice. Astro 5 solves most of them at the framework level, rather than leaving them as an exercise for the reader. After implementing i18n on this site, we’ve put together a complete recipe for bilingualism that works — routing, translations, SEO, blog.

Configuring astro.config.mjs

Add an i18n section to the Astro configuration:

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

export default defineConfig({
  i18n: {
    defaultLocale: 'pl',
    locales: ['pl', 'en'],
    routing: {
      prefixDefaultLocale: true,
    },
  },
});

prefixDefaultLocale: true means all languages have a URL prefix — /pl/ and /en/. Alternatively, prefixDefaultLocale: false means the default language (pl) has no prefix (available at /), and only /en/ has a prefix. For SEO the prefix option is better — it avoids canonical URL and hreflang issues.

After this configuration, Astro automatically generates routing for pages in src/pages/pl/ and src/pages/en/. The page src/pages/pl/index.astro is accessible at /pl/, and src/pages/en/index.astro at /en/.

Translation files

Translations as TypeScript const provide full autocomplete and compilation errors for missing keys:

// src/i18n/pl.ts
export default {
  'nav.home': 'Strona główna',
  'nav.blog': 'Blog',
  'nav.about': 'O nas',
  'nav.contact': 'Kontakt',
  'hero.title': 'Twórz lepsze strony',
  'hero.subtitle': 'Szybko, bezpiecznie, z przyjemnością.',
  'blog.readMore': 'Czytaj dalej',
  'blog.publishedOn': 'Opublikowano',
} as const;

// src/i18n/en.ts
export default {
  'nav.home': 'Home',
  'nav.blog': 'Blog',
  'nav.about': 'About',
  'nav.contact': 'Contact',
  'hero.title': 'Build better sites',
  'hero.subtitle': 'Fast, secure, enjoyable.',
  'blog.readMore': 'Read more',
  'blog.publishedOn': 'Published on',
} as const;

TypeScript infers literal types of keys from as const. If in a component you call t('nav.hom') (typo), the IDE will underline the error before compilation. This eliminates an entire class of bugs where a translation “works” but displays the key instead of text.

The useTranslations hook

Two hooks in src/i18n/utils.ts simplify using translations in components:

// src/i18n/utils.ts
import pl from './pl';
import en from './en';

const translations = { pl, en } as const;

export function getLangFromUrl(url: URL): 'pl' | 'en' {
  const [, lang] = url.pathname.split('/');
  if (lang === 'en') return 'en';
  return 'pl';
}

export function useTranslations(lang: 'pl' | 'en') {
  return function t(key: keyof typeof pl): string {
    return translations[lang][key] ?? key;
  };
}

Usage in an Astro component:

---
// src/pages/pl/index.astro
import { getLangFromUrl, useTranslations } from '../../i18n/utils';

const lang = getLangFromUrl(Astro.url);
const t = useTranslations(lang);
---

<nav>
  <a href={`/${lang}/`}>{t('nav.home')}</a>
  <a href={`/${lang}/blog/`}>{t('nav.blog')}</a>
</nav>

Alternatively you can use Astro.params.lang if the page has a dynamic [lang] segment in the path — then getLangFromUrl isn’t needed.

hreflang for SEO

hreflang tags inform search engines about language versions of a page and prevent content duplication in the index:

---
// src/layouts/BaseLayout.astro
const { title, description, currentPath } = Astro.props;
const locales = ['pl', 'en'];
const baseUrl = 'https://perkun.eu';
---

<head>
  <meta charset="utf-8" />
  <title>{title}</title>
  <meta name="description" content={description} />

  {locales.map(locale => (
    <link
      rel="alternate"
      hreflang={locale}
      href={`${baseUrl}/${locale}${currentPath}`}
    />
  ))}
  <link
    rel="alternate"
    hreflang="x-default"
    href={`${baseUrl}/pl${currentPath}`}
  />
  <link rel="canonical" href={`${baseUrl}/${Astro.params.lang ?? 'pl'}${currentPath}`} />
</head>

hreflang="x-default" points to the default language version for users whose language isn’t supported by your site. It typically points to the main language.

Bilingual blog

Filtering posts by language in getCollection:

// src/pages/pl/blog/index.astro
import { getCollection } from 'astro:content';

const posts = await getCollection('blog', (post) => {
  return post.data.lang === 'pl';
});

const sortedPosts = posts.sort(
  (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);

Markdown file naming convention: pl-2026-04-07-polish-title.md with lang: pl in frontmatter, en-2026-04-07-english-title.md with lang: en. The language prefix in the filename prevents URL conflicts — two posts on the same date with similar titles in different languages have unique slugs.

Blog routing via [...slug].astro:

---
// src/pages/[lang]/blog/[...slug].astro
import { getCollection } from 'astro:content';

export async function getStaticPaths() {
  const posts = await getCollection('blog');
  return posts.map(post => ({
    params: {
      lang: post.data.lang,
      slug: post.slug,
    },
    props: { post },
  }));
}
---

Language switcher

The LanguageSwitcher.astro component must handle the case where slugs differ between languages:

---
// src/components/LanguageSwitcher.astro
const currentPath = Astro.url.pathname;
const isPolish = currentPath.startsWith('/pl');

// Simple prefix swap — works for pages with the same path in both languages
const alternateHref = isPolish
  ? currentPath.replace('/pl', '/en')
  : currentPath.replace('/en', '/pl');

const alternateLabel = isPolish ? 'EN' : 'PL';
---

<a href={alternateHref} lang={isPolish ? 'en' : 'pl'}>
  {alternateLabel}
</a>

Note: this simple implementation assumes paths are symmetric between languages (/pl/services//en/services/). If you have different slugs per language (e.g. /pl/uslugi/ vs /en/services/), you need slug mapping in Content Collections or a separate configuration file with translation pairs.

Summary

Astro 5’s built-in i18n eliminates the need for external packages for bilingualism. Translations as TypeScript const provide type safety. hreflang generated automatically from the layout. Blog filtered by lang in frontmatter. The whole thing takes about half a working day to implement on an existing Astro site.