Jakub.
English
tutorial

i18n w Astro 5 - wielojęzyczna strona bez bibliotek

Jakub Nalewajk · 11 marca 2026

i18n w Astro 5

Dodanie wielu języków do strony w Astro brzmi prosto, dopóki nie zdasz sobie sprawy, że dotyczy to routingu, treści, SEO i UI jednocześnie. Po zbudowaniu pełnego wsparcia i18n dla mojego portfolio (polski + angielski) chcę podzielić się wzorcem, który zadziałał - bez zewnętrznych bibliotek i18n, tylko wbudowane funkcje Astro i kilka funkcji pomocniczych.

Ten poradnik obejmuje pełną konfigurację: config, tłumaczenia, routing, kolekcje treści, przełącznik języka, SEO i detekcję języka przeglądarki.

Struktura projektu

Tak wyglądają pliki związane z i18n:

src/
├── i18n/
│   ├── config.ts          # Języki, domyślne ustawienia, mapowania
│   ├── utils.ts           # Funkcje pomocnicze
│   ├── index.ts           # Re-eksporty
│   └── translations/
│       ├── en.ts           # Angielskie teksty
│       ├── pl.ts           # Polskie teksty
│       └── index.ts        # Mapa tłumaczeń
├── content/
│   └── posts/
│       ├── en/             # Angielskie posty
│       └── pl/             # Polskie posty
├── pages/
│   └── [...locale]/        # Ścieżki zależne od języka
│       ├── index.astro
│       ├── blog/
│       │   ├── [...page].astro
│       │   └── [slug]/index.astro
│       └── category/
│           └── [tag]/[...page].astro

Krok 1: Konfiguracja obsługiwanych języków

Zacznij od jednego pliku konfiguracyjnego, który definiuje wspierane języki i wszystkie powiązane mapowania:

// src/i18n/config.ts
export const LOCALES = ['pl', 'en'] as const
export type Locale = (typeof LOCALES)[number]

export const DEFAULT_LOCALE: Locale = 'pl'

export const LOCALE_LABELS: Record<Locale, string> = {
  pl: 'Polski',
  en: 'English',
}

export const LOCALE_HTML_LANG: Record<Locale, string> = {
  pl: 'pl',
  en: 'en',
}

export const LOCALE_OG: Record<Locale, string> = {
  pl: 'pl_PL',
  en: 'en_US',
}

export const LOCALE_BCP47: Record<Locale, string> = {
  pl: 'pl-PL',
  en: 'en-US',
}

/**
 * Zlokalizowane ścieżki dla stron z różnymi slugami per locale.
 * Inspirowane konfiguracją pathnames z next-intl.
 * Klucz = wewnętrzny identyfikator trasy, wartość = ścieżka per locale (lub string jeśli taka sama).
 */
export const PATHNAMES = {
  '/': '/',
  '/blog': '/blog',
  '/services': {
    pl: '/uslugi',
    en: '/services',
  },
  '/blog/deploying-astro-on-dokploy': {
    pl: '/blog/wdrazanie-astro-na-dokploy',
    en: '/blog/deploying-astro-on-dokploy',
  },
  // ... kolejne przetłumaczone strony i posty
} as const satisfies Record<string, string | Record<Locale, string>>

Każda decyzja związana z językiem w aplikacji odwołuje się do tego pliku. PATHNAMES pełni rolę jedynego źródła prawdy dla stron z różnymi URLami per locale - zarówno statycznych podstron (jak /uslugi vs /en/services) jak i postów blogowych. Dodanie nowej przetłumaczonej strony = jedna linijka w PATHNAMES.

Krok 2: Konfiguracja Astro i18n

Włącz wbudowane i18n Astro w astro.config.mjs:

// astro.config.mjs
import { DEFAULT_LOCALE, LOCALES } from './src/i18n/config'

export default defineConfig({
  i18n: {
    defaultLocale: DEFAULT_LOCALE,
    locales: [...LOCALES],
    routing: {
      prefixDefaultLocale: false,
    },
  },
  // ...
})

Z prefixDefaultLocale: false domyślny język (polski) żyje pod / i /blog/..., podczas gdy angielski dostaje /en/ i /en/blog/.... To pozwala zachować istniejące URLe stabilne, gdy dodajesz i18n do istniejącej strony.

Krok 3: Teksty tłumaczeń

Każdy język dostaje własny plik TypeScript ze wszystkimi tekstami UI:

// src/i18n/translations/pl.ts
export default {
  meta: {
    title: 'Jakub Nalewajk - Programista Frontend',
    description: 'Programista frontend z Pułtuska...',
    keywords: 'programista, frontend, React...',
    blogTitle: 'Blog - Jakub Nalewajk',
    blogDescription: 'Blog o programowaniu...',
  },
  nav: {
    home: 'Strona główna',
    experience: 'Doświadczenie',
    projects: 'Projekty',
    contact: 'Kontakt',
    blog: 'Blog',
  },
  hero: {
    jobTitle: 'Frontend Developer',
    description: 'Programista frontend z doświadczeniem...',
    cta: 'Napisz do mnie',
  },
  // ... pozostałe sekcje UI
} as const
// src/i18n/translations/en.ts
export default {
  meta: {
    title: 'Jakub Nalewajk - Frontend Developer',
    description: 'Frontend Developer from Poland...',
    // ...
  },
  // ... taka sama struktura jak pl.ts
} as const
// src/i18n/translations/index.ts
import en from './en'
import pl from './pl'

export const translations = { pl, en } as const

Asercja as const daje pełne bezpieczeństwo typów - TypeScript wie dokładnie, jakie klucze i wartości istnieją. Jeśli dodasz klucz do jednego języka, system typów przypomni ci o dodaniu go do drugiego.

Krok 4: Funkcje pomocnicze

Te helpery zasilają całą logikę i18n na stronie:

// src/i18n/utils.ts
import { DEFAULT_LOCALE, LOCALE_BCP47, LOCALES, PATHNAMES, type Locale } from './config'
import { translations } from './translations'

export type Translations = (typeof translations)[Locale]

// Wykryj język z URL lub stringa Astro.currentLocale
export function getLocale(input?: URL | string): Locale {
  if (input instanceof URL) {
    const [, segment] = input.pathname.split('/')
    const match = LOCALES.find((l) => l === segment)
    return match ?? DEFAULT_LOCALE
  }
  const match = LOCALES.find((l) => l === input)
  return match ?? DEFAULT_LOCALE
}

// Pobierz teksty tłumaczeń dla danego języka
export function t(locale: Locale): Translations {
  return translations[locale]
}

// Pobierz wszystkie języki poza bieżącym
export function getAlternateLocales(locale: Locale): Locale[] {
  return LOCALES.filter((l) => l !== locale)
}

// Zwraca undefined dla domyślnego języka (używane w parametrach [...locale])
export function getLocaleParam(locale: Locale): Locale | undefined {
  return locale === DEFAULT_LOCALE ? undefined : locale
}

// Zbuduj zlokalizowaną ścieżkę URL (używaj dla URLi z hash fragmentami jak /#section)
// Dla zwykłych ścieżek preferuj getRelativeLocaleUrl() z astro:i18n
export function getLocalizedPathname(path: string, locale: Locale): string {
  const cleanPath = path.startsWith('/') ? path : `/${path}`
  if (locale === DEFAULT_LOCALE) return cleanPath
  return `/${locale}${cleanPath}`
}

// Usuń prefiks języka ze ścieżki
const localePrefixRegex = new RegExp(
  `^\\/(${LOCALES.filter((l) => l !== DEFAULT_LOCALE).join('|')})(?=\\/|$)`
)

export function stripLocaleFromPath(path: string): string {
  return path.replace(localePrefixRegex, '') || '/'
}

// Rozwiąż klucz PATHNAMES na pełny zlokalizowany URL
export function getLocalizedRoute(key: keyof typeof PATHNAMES, locale: Locale): string {
  const entry = PATHNAMES[key]
  const path = typeof entry === 'string' ? entry : entry[locale]
  return getLocalizedPathname(path, locale)
}

// Pobierz URL alternatywnego języka dla klucza PATHNAMES
export function getAlternateRoute(key: keyof typeof PATHNAMES, locale: Locale): string {
  const alt = getAlternateLocales(locale)[0]
  return getLocalizedRoute(key, alt)
}

// Zbuduj mapę hreflang do sitemapy na podstawie PATHNAMES
export function buildHreflangMap(siteUrl: string) {
  const map = new Map<string, { url: string; lang: string }[]>()

  for (const entry of Object.values(PATHNAMES)) {
    const paths: Record<Locale, string> = {} as Record<Locale, string>
    for (const l of LOCALES) {
      const path = typeof entry === 'string' ? entry : entry[l]
      paths[l] = getLocalizedPathname(path, l)
    }

    const links = [
      ...LOCALES.map((l) => ({
        url: `${siteUrl}${paths[l]}/`,
        lang: LOCALE_BCP47[l],
      })),
      {
        url: `${siteUrl}${paths[DEFAULT_LOCALE]}/`,
        lang: 'x-default',
      },
    ]

    for (const l of LOCALES) {
      map.set(`${siteUrl}${paths[l]}/`, links)
    }
  }

  return map
}

// Formatuj daty zgodnie z konwencjami danego języka
export function formatDate(date: Date | string, locale: Locale = DEFAULT_LOCALE) {
  return new Intl.DateTimeFormat(LOCALE_BCP47[locale], {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  }).format(new Date(date))
}

Funkcja getLocale() przyjmuje zarówno obiekt URL (do użycia w zwykłych funkcjach narzędziowych jak seo.ts), jak i string (do użycia z Astro.currentLocale w komponentach). W komponentach .astro preferuj getLocale(Astro.currentLocale) zamiast getLocale(Astro.url) - korzysta z wbudowanej detekcji języka Astro, która jest bardziej niezawodna.

Trzy nowe helpery - getLocalizedRoute(), getAlternateRoute() i buildHreflangMap() - operują na PATHNAMES z configa. Dzięki temu zarządzanie zlokalizowanymi URLami jest scentralizowane: definiujesz mapowanie ścieżek raz w PATHNAMES, a każda część aplikacji (nawigacja, tagi hreflang, sitemap) wyprowadza poprawne URLe z tego jednego źródła.

Do budowania zlokalizowanych URLi Astro udostępnia getRelativeLocaleUrl() z astro:i18n, która automatycznie respektuje twoją konfigurację. Naszego custom getLocalizedPathname() używaj tylko dla URLi z hash fragmentami (jak /#experience), ponieważ getRelativeLocaleUrl() niepoprawnie dodaje trailing slash do fragmentu.

Kluczowy mechanizm to getLocaleParam() - zwraca undefined dla domyślnego języka. To działa z parametrem rest [...locale] Astro: undefined generuje /, a 'en' generuje /en/.

Krok 5: Routing zależny od języka

Parametr rest [...locale] to sztuczka routingowa, która sprawia, że wszystko działa. Oto strona główna:

---
// src/pages/[...locale]/index.astro
import Layout from '@layouts/Layout.astro'
import { LOCALES } from '@i18n/config'
import { t, getLocaleParam } from '@i18n'
import type { InferGetStaticPropsType } from 'astro'

export function getStaticPaths() {
  return LOCALES.map((locale) => ({
    params: { locale: getLocaleParam(locale) },
    props: { locale },
  }))
}

type Props = InferGetStaticPropsType<typeof getStaticPaths>
const { locale } = Astro.props
const strings = t(locale)
---

<Layout title={strings.meta.title} description={strings.meta.description}>
  <h1>{strings.hero.jobTitle}</h1>
  <p>{strings.hero.description}</p>
  <!-- reszta strony -->
</Layout>

To generuje dwie strony:

  • /index.html (polski, parametr locale to undefined)
  • /en/index.html (angielski, parametr locale to 'en')

Ten sam wzorzec działa dla paginowanych ścieżek. Zwróć uwagę, że InferGetStaticPropsType inferuje też prop page, który Astro automatycznie wstrzykuje przez paginate():

---
// src/pages/[...locale]/blog/[...page].astro
import type { InferGetStaticPropsType } from 'astro'

export async function getStaticPaths({ paginate }) {
  const posts = await getCollection('posts')

  return LOCALES.flatMap((locale) => {
    const publishedPosts = getPostsForLocale(posts, locale)
    return paginate(publishedPosts, {
      pageSize: 6,
      params: { locale: getLocaleParam(locale) },
      props: { locale },
    })
  })
}

type Props = InferGetStaticPropsType<typeof getStaticPaths>
const { locale, page } = Astro.props
---

Strony z różnymi slugami per locale

Niektóre strony potrzebują zupełnie innych URLi per język. Na przykład strona usług może żyć pod /uslugi po polsku i /en/services po angielsku. Ponieważ to są różne slugi, sam parametr rest [...locale] ich nie obsłuży - potrzebujesz osobnych plików stron.

Najpierw zdefiniuj mapowanie w PATHNAMES (już zrobione w Kroku 1):

export const PATHNAMES = {
  '/services': {
    pl: '/uslugi',
    en: '/services',
  },
  // ...
}

Następnie stwórz dwa pliki stron - po jednym dla sluga każdego języka:

---
// src/pages/uslugi.astro (wersja polska)
import Layout from '@layouts/Layout.astro'
import { t, getAlternateRoute } from '@i18n'

const locale = 'pl'
const strings = t(locale)
const alternateHref = getAlternateRoute('/services', locale)
---

<Layout title={strings.services.title} alternateHref={alternateHref}>
  <!-- Polska treść usług -->
</Layout>
---
// src/pages/[...locale]/services.astro (wersja angielska)
import Layout from '@layouts/Layout.astro'
import { LOCALES } from '@i18n/config'
import { t, getLocaleParam, getAlternateRoute } from '@i18n'
import type { InferGetStaticPropsType } from 'astro'

export function getStaticPaths() {
  return LOCALES.filter((l) => l !== 'pl').map((locale) => ({
    params: { locale: getLocaleParam(locale) },
    props: { locale },
  }))
}

type Props = InferGetStaticPropsType<typeof getStaticPaths>
const { locale } = Astro.props
const strings = t(locale)
const alternateHref = getAlternateRoute('/services', locale)
---

<Layout title={strings.services.title} alternateHref={alternateHref}>
  <!-- Angielska treść usług -->
</Layout>

W nawigacji używaj getLocalizedRoute(), żeby zawsze wskazywać na poprawny URL:

---
import { getLocalizedRoute } from '@i18n'
---
<a href={getLocalizedRoute('/services', locale)}>
  {strings.nav.services}
</a>

Wywołanie getAlternateRoute() generuje poprawny URL hreflang dla drugiego języka, a getLocalizedRoute() rozwiązuje właściwą ścieżkę dla dowolnego locale - wszystko wyprowadzone z jednej definicji PATHNAMES.

Krok 6: Wielojęzyczne kolekcje treści

Posty blogowe są zorganizowane według języka w systemie plików:

src/content/posts/
├── en/
│   ├── deploying-astro-on-dokploy.mdx
│   └── my-programming-journey.mdx
└── pl/
    ├── wdrazanie-astro-na-dokploy.mdx
    └── moja-droga-w-programowaniu.mdx

Każdy post ma pole lang i opcjonalny translationKey do łączenia tłumaczeń:

---
title: 'Moja droga w programowaniu'
lang: 'pl'
translationKey: 'my-programming-journey'
# ... inne frontmatter
---

Schemat treści waliduje te pola:

// src/content.config.ts
import { LOCALES } from '@i18n/config'

const postsCollection = defineCollection({
  loader: glob({ pattern: '**/*.mdx', base: './src/content/posts' }),
  schema: ({ image }) =>
    z.object({
      title: z.string(),
      pubDate: z.date(),
      description: z.string(),
      author: z.string(),
      category: z.enum(['blog', 'news', 'tutorial', 'programming']),
      draft: z.boolean(),
      image: z.object({ url: image(), alt: z.string() }),
      tags: z.array(z.string()),
      lang: z.enum(LOCALES),
      translationKey: z.string().optional(),
    }),
})

Filtrowanie postów według języka jest proste:

// src/utils/posts.ts
export const getPostsForLocale = (posts: CollectionEntry<'posts'>[], locale: Locale) =>
  posts
    .filter((p) => p.data.lang === locale && !p.data.draft)
    .sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime())

Krok 7: Łączenie przetłumaczonych postów

Przy renderowaniu posta blogowego znajdź jego tłumaczenie i przekaż alternatywny URL dla hreflang:

---
// src/pages/[...locale]/blog/[slug]/index.astro
export async function getStaticPaths() {
  const allPosts = await getCollection('posts')

  return LOCALES.flatMap((locale) => {
    const publishedEntries = getPostsForLocale(allPosts, locale)

    return publishedEntries.map((entry) => {
      const slug = stripLocalePrefix(entry.id)

      // Znajdź przetłumaczony odpowiednik po translationKey
      const translated = allPosts.find(
        (p) =>
          p.data.translationKey &&
          p.data.translationKey === entry.data.translationKey &&
          p.data.lang !== locale,
      )

      let alternateHref: string | undefined
      if (translated) {
        const translatedSlug = stripLocalePrefix(translated.id)
        alternateHref = getLocalizedPathname(
          `/blog/${translatedSlug}/`,
          translated.data.lang,
        )
      }

      return {
        params: { locale: getLocaleParam(locale), slug },
        props: { entry, alternateHref },
      }
    })
  })
}
---

alternateHref jest następnie używany w layoucie do generowania poprawnych tagów <link rel="alternate" hreflang="...">.

Krok 8: Komponent przełącznika języka

Przełącznik używa linków hreflang już obecnych w <head>, żeby znaleźć poprawny URL dla docelowego języka, a preferencję zapisuje przez API po stronie serwera:

---
// src/components/molecules/LanguageSwitcher.astro
import {
  getAlternateLocales,
  getLocale,
  stripLocaleFromPath,
  LOCALE_LABELS,
} from '@i18n'
import { LOCALE_BCP47 } from '@i18n/config'
import { getRelativeLocaleUrl } from 'astro:i18n'

const locale = getLocale(Astro.currentLocale)
const [targetLocale] = getAlternateLocales(locale)
const basePath = stripLocaleFromPath(Astro.url.pathname)
const href = getRelativeLocaleUrl(targetLocale, basePath)
const targetHreflang = LOCALE_BCP47[targetLocale]
---

<a
  href={href}
  class="px-2 py-1 text-xs font-mono rounded-md border"
  data-locale-switch={targetLocale}
  data-target-hreflang={targetHreflang}
>
  {LOCALE_LABELS[targetLocale]}
</a>

<script>
  function initLanguageSwitcher() {
    for (const link of document.querySelectorAll<HTMLAnchorElement>(
      '[data-locale-switch]',
    )) {
      const targetLocale = link.dataset.localeSwitch
      const targetHreflang = link.dataset.targetHreflang

      // Użyj linków hreflang dla postów z różnymi slugami
      const hreflangLink = document.querySelector<HTMLLinkElement>(
        `link[rel="alternate"][hreflang="${targetHreflang}"]`,
      )
      if (hreflangLink?.href) {
        link.href = new URL(hreflangLink.href).pathname
      }

      // Zapisz preferencję przez API po stronie serwera (ciasteczko httpOnly)
      link.addEventListener('click', () => {
        if (targetLocale) {
          navigator.sendBeacon('/api/set-language', targetLocale)
        }
      })
    }
  }
  initLanguageSwitcher()
  document.addEventListener('astro:page-load', initLanguageSwitcher)
</script>

Preferencja językowa jest zapisywana jako ciasteczko httpOnly przez endpoint API po stronie serwera, co jest bezpieczniejsze niż ustawianie ciasteczek po stronie klienta:

// src/pages/api/set-language.ts
import type { APIRoute } from 'astro'
import { LOCALES } from '@i18n/config'

export const prerender = false

export const POST: APIRoute = async ({ cookies, request }) => {
  const locale = await request.text()

  if (!LOCALES.includes(locale as (typeof LOCALES)[number])) {
    return new Response('Invalid locale', { status: 400 })
  }

  cookies.set('preferred-locale', locale, {
    path: '/',
    maxAge: 31536000,
    sameSite: 'lax',
    secure: true,
    httpOnly: true,
  })

  return new Response('OK', { status: 200 })
}

Używamy navigator.sendBeacon() w handlerze kliknięcia, ponieważ wysyła żądanie asynchronicznie bez blokowania nawigacji - ciasteczko jest ustawiane po stronie serwera, podczas gdy przeglądarka nawiguje do nowego URL.

Kluczowy trik dla postów blogowych: ponieważ przetłumaczone posty mają różne slugi (jak /blog/moja-droga/ vs /en/blog/my-journey/), komponent odczytuje link hreflang z <head>, żeby uzyskać prawdziwy przetłumaczony URL zamiast prostej zamiany prefiksu języka.

Uwaga: Przycisk wstecz w przeglądarce

Jest subtelny problem UX z domyślną nawigacją przez <a> - zmiana języka dodaje nowy wpis do historii przeglądarki. Więc gdy użytkownik czyta post, przełączy się na angielski i wciśnie przycisk wstecz, wróci do polskiej wersji zamiast do poprzedniej strony.

Rozwiązaniem jest użycie window.location.replace() zamiast normalnej nawigacji. To zastępuje bieżący wpis w historii zamiast dodawać nowy:

link.addEventListener('click', (e) => {
  if (targetLocale) {
    navigator.sendBeacon('/api/set-language', targetLocale)
  }

  e.preventDefault()
  window.location.replace(link.href)
})

Teraz przycisk wstecz zabiera użytkownika na faktycznie poprzednią stronę, niezależnie od tego ile razy przełączał język.

Krok 9: Detekcja języka po stronie serwera

Zamiast wykrywać język przeglądarki po stronie klienta (co powoduje widoczne przekierowanie i obniża wyniki Lighthouse), obsługujemy to po stronie serwera. Strona główna / jest renderowana na serwerze i wykorzystuje wbudowany preferredLocale Astro do sprawdzenia nagłówka Accept-Language:

---
// src/pages/index.astro
import { DEFAULT_LOCALE } from '@i18n/config'
import { t, getLocale } from '@i18n'

export const prerender = false

const COOKIE_NAME = 'preferred-locale'
const preferred = Astro.cookies.get(COOKIE_NAME)?.value

if (!preferred) {
  const match = getLocale(Astro.preferredLocale)

  if (match !== DEFAULT_LOCALE) {
    Astro.cookies.set(COOKIE_NAME, match, {
      path: '/',
      maxAge: 31536000,
      sameSite: 'lax',
      secure: Astro.url.protocol === 'https:',
      httpOnly: true,
    })
    return Astro.redirect(`/${match}`, 302)
  }
}

const strings = t(DEFAULT_LOCALE)
---

<Layout title={strings.meta.title} description={strings.meta.description}>
  <!-- Polska treść strony głównej -->
</Layout>

To podejście jest znacznie lepsze od alternatywy po stronie klienta:

  • Brak podwójnego ładowania strony - przekierowanie następuje zanim jakikolwiek HTML zostanie wysłany
  • Brak kary w Lighthouse - podejście klienckie kosztowało ~1,5 sekundy w czasie przekierowań
  • Astro.preferredLocale poprawnie parsuje Accept-Language z wartościami jakości (np. en-US,en;q=0.9,pl;q=0.8), w przeciwieństwie do naiwnego parsowania navigator.language

Strona [...locale]/index.astro nadal generuje prerendered stronę /en/ przez getStaticPaths, ale pomija domyślny język, ponieważ / jest teraz obsługiwany przez SSR index.astro. Narzut SSR jest minimalny - brak zapytań do bazy danych czy API, tylko renderowanie szablonu.

Krok 10: SEO - Hreflang i Sitemap

Twój layout powinien zawierać linki hreflang dla każdej wersji językowej. Ważne: używaj formatu BCP47 (np. pl-PL, en-US) zamiast samych kodów języka - to zapewnia spójność z sitemapą i lepszą interpretację przez Google:

---
// W <head> Layout.astro
---
{LOCALES.map((l) => (
  <link rel="alternate" hreflang={LOCALE_BCP47[l]} href={seo.hreflangUrls[l]} />
))}
<link rel="alternate" hreflang="x-default" href={seo.hreflangUrls[DEFAULT_LOCALE]} />

Dla sitemapy skonfiguruj @astrojs/sitemap ze wsparciem i18n. Opcja i18n pluginu automatycznie dodaje linki hreflang na podstawie prefiksów locale w URL, ale nie generuje tagu x-default i nie potrafi dopasować stron z różnymi slugami per język - /blog/moja-droga-w-programowaniu/ vs /en/blog/my-programming-journey/. Zamiast ręcznie utrzymywać listę przetłumaczonych par URLi, używamy buildHreflangMap(), która generuje pełną mapę hreflang (z x-default) bezpośrednio z PATHNAMES - jedynego źródła prawdy:

// astro.config.mjs
import sitemap from '@astrojs/sitemap'
import { DEFAULT_LOCALE, LOCALE_BCP47, LOCALES } from './src/i18n/config'
import { buildHreflangMap } from './src/i18n/utils'

const SITE = 'https://jnalewajk.me'
const hreflangMap = buildHreflangMap(SITE)

export default defineConfig({
  integrations: [
    sitemap({
      i18n: {
        defaultLocale: DEFAULT_LOCALE,
        locales: LOCALE_BCP47,
      },
      serialize(item) {
        const links = hreflangMap.get(item.url)
        if (links) item.links = links
        return item
      },
    }),
  ],
})

Każdy wpis w PATHNAMES jest automatycznie uwzględniony w mapie hreflang sitemapy - zarówno te ze wspólną ścieżką (jak /blog), jak i te z różnymi slugami per locale. Mapa zawiera też tag x-default wskazujący na domyślny język, co pomaga Google poprawnie zrozumieć relacje między wersjami językowymi. Gdy dodasz nową przetłumaczoną stronę lub post, wystarczy zaktualizować PATHNAMES - sitemap przejmie to bez dodatkowej konfiguracji.

Podsumowanie

Wzorzec sprowadza się do kilku zasad:

Jeden plik konfiguracyjny definiuje wszystkie obsługiwane języki. Parametry rest [...locale] obsługują routing - undefined dla domyślnego, kod języka dla pozostałych. Pliki tłumaczeń to zwykłe obiekty TypeScript z as const dla bezpieczeństwa typów. Kolekcje treści używają pola lang do filtrowania i translationKey do łączenia tłumaczeń. Linki hreflang w <head> służą podwójnie - SEO i nawigacja przełącznika języka. Detekcja przeglądarki uruchamia się raz i respektuje preferencję ciasteczka użytkownika. PATHNAMES to jedyne źródło prawdy dla zlokalizowanych URLi - nawigacja, hreflang i sitemap wyprowadzają dane z niego.

Cały system to około 200 linii kodu narzędziowego, w pełni type-safe, bez zewnętrznych bibliotek i18n.

Jeśli szukasz sposobu na self-hosting strony Astro po skonfigurowaniu i18n, sprawdź mój poradnik o wdrażaniu Astro 5 na Dokploy - ta sama strona, na której działa ten system i18n.

Udostępnij ten post: