i18n w Astro 5 - wielojęzyczna strona bez bibliotek
Jakub Nalewajk · 11 marca 2026
Na tej stronie
- 01 Struktura projektu
- 02 Krok 1: Konfiguracja obsługiwanych języków
- 03 Krok 2: Konfiguracja Astro i18n
- 04 Krok 3: Teksty tłumaczeń
- 05 Krok 4: Funkcje pomocnicze
- 06 Krok 5: Routing zależny od języka
- 07 Strony z różnymi slugami per locale
- 08 Krok 6: Wielojęzyczne kolekcje treści
- 09 Krok 7: Łączenie przetłumaczonych postów
- 10 Krok 8: Komponent przełącznika języka
- 11 Uwaga: Przycisk wstecz w przeglądarce
- 12 Krok 9: Detekcja języka po stronie serwera
- 13 Krok 10: SEO - Hreflang i Sitemap
- 14 Podsumowanie
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, parametrlocaletoundefined)/en/index.html(angielski, parametrlocaleto'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.preferredLocalepoprawnie parsujeAccept-Languagez wartościami jakości (np.en-US,en;q=0.9,pl;q=0.8), w przeciwieństwie do naiwnego parsowanianavigator.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.