Astro 5 i18n Guide - Multilingual Site Without Libraries
Jakub Nalewajk · March 11, 2026
On this page
- 01 Project Structure
- 02 Step 1: Locale Configuration
- 03 Step 2: Astro i18n Config
- 04 Step 3: Translation Strings
- 05 Step 4: Utility Functions
- 06 Step 5: Locale-Aware Routing
- 07 Pages with Different Slugs per Locale
- 08 Step 6: Multilingual Content Collections
- 09 Step 7: Linking Translated Blog Posts
- 10 Step 8: Language Switcher Component
- 11 Caveat: Browser Back Button
- 12 Step 9: Server-Side Locale Detection
- 13 Step 10: SEO - Hreflang and Sitemap
- 14 Summary
Adding multiple languages to an Astro site sounds simple until you realize it touches routing, content, SEO, and UI all at once. After building full i18n support for my portfolio (Polish + English), I want to share the pattern that worked - no external i18n libraries, just Astro’s built-in features and a few utility functions.
This guide covers the full setup: config, translations, routing, content collections, language switching, SEO, and browser-based locale detection.
Project Structure
Here is what the i18n-related files look like:
src/
├── i18n/
│ ├── config.ts # Locales, defaults, mappings
│ ├── utils.ts # Helper functions
│ ├── index.ts # Re-exports
│ └── translations/
│ ├── en.ts # English strings
│ ├── pl.ts # Polish strings
│ └── index.ts # Translation map
├── content/
│ └── posts/
│ ├── en/ # English blog posts
│ └── pl/ # Polish blog posts
├── pages/
│ └── [...locale]/ # Locale-aware routes
│ ├── index.astro
│ ├── blog/
│ │ ├── [...page].astro
│ │ └── [slug]/index.astro
│ └── category/
│ └── [tag]/[...page].astro
Step 1: Locale Configuration
Start with a single config file that defines your locales and all related mappings:
// 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',
}
/**
* Localized pathnames for pages with different slugs per locale.
* Inspired by the pathnames config from next-intl.
* Key = internal route identifier, value = path per locale (or string if identical).
*/
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',
},
// ... more translated pages and posts
} as const satisfies Record<string, string | Record<Locale, string>>
Every locale-related decision in the app references this file. PATHNAMES serves as the single source of truth for pages with different URLs per locale - both static subpages (like /uslugi vs /en/services) and blog posts. Adding a new translated page = one line in PATHNAMES.
Step 2: Astro i18n Config
Enable Astro’s built-in i18n in 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,
},
},
// ...
})
With prefixDefaultLocale: false, the default locale (Polish) lives at / and /blog/..., while English gets /en/ and /en/blog/.... This keeps existing URLs stable when you add i18n to an existing site.
Step 3: Translation Strings
Each locale gets its own TypeScript file with all UI strings:
// 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',
},
// ... all other UI sections
} as const
// src/i18n/translations/en.ts
export default {
meta: {
title: 'Jakub Nalewajk - Frontend Developer',
description: 'Frontend Developer from Poland...',
// ...
},
// ... same structure as pl.ts
} as const
// src/i18n/translations/index.ts
import en from './en'
import pl from './pl'
export const translations = { pl, en } as const
The as const assertion gives you full type safety - TypeScript knows exactly what keys and values exist. If you add a key to one language, the type system will remind you to add it to the other.
Step 4: Utility Functions
These helpers power all i18n logic across the site:
// 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]
// Detect locale from URL or Astro.currentLocale string
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
}
// Get translation strings for a locale
export function t(locale: Locale): Translations {
return translations[locale]
}
// Get all locales except the current one
export function getAlternateLocales(locale: Locale): Locale[] {
return LOCALES.filter((l) => l !== locale)
}
// Returns undefined for default locale (used in [...locale] params)
export function getLocaleParam(locale: Locale): Locale | undefined {
return locale === DEFAULT_LOCALE ? undefined : locale
}
// Build a localized URL path (use for hash fragment URLs like /#section)
// For regular paths, prefer getRelativeLocaleUrl() from 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}`
}
// Remove locale prefix from a path
const localePrefixRegex = new RegExp(
`^\\/(${LOCALES.filter((l) => l !== DEFAULT_LOCALE).join('|')})(?=\\/|$)`
)
export function stripLocaleFromPath(path: string): string {
return path.replace(localePrefixRegex, '') || '/'
}
// Resolve a PATHNAMES key to a full localized 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)
}
// Get the alternate locale's URL for a PATHNAMES key
export function getAlternateRoute(key: keyof typeof PATHNAMES, locale: Locale): string {
const alt = getAlternateLocales(locale)[0]
return getLocalizedRoute(key, alt)
}
// Build a sitemap hreflang map from 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
}
// Format dates using the locale's conventions
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))
}
The getLocale() function accepts both a URL object (for use in plain utility functions like seo.ts) and a string (for use with Astro.currentLocale in components). In .astro components, prefer getLocale(Astro.currentLocale) over getLocale(Astro.url) - it uses Astro’s built-in locale detection which is more reliable.
The three new helpers - getLocalizedRoute(), getAlternateRoute(), and buildHreflangMap() - all operate on PATHNAMES from the config. This means localized URL management is centralized: you define the path mapping once in PATHNAMES, and every part of the app (navigation, hreflang tags, sitemap) derives the correct URLs from that single source.
For building localized URLs, Astro provides getRelativeLocaleUrl() from astro:i18n which respects your config automatically. Use our custom getLocalizedPathname() only for URLs with hash fragments (like /#experience) since getRelativeLocaleUrl() incorrectly appends a trailing slash to the fragment.
The key insight is getLocaleParam() - it returns undefined for the default locale. This works with Astro’s [...locale] rest parameter: undefined produces / while 'en' produces /en/.
Step 5: Locale-Aware Routing
The [...locale] rest parameter is the routing trick that makes everything work. Here is the homepage:
---
// 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>
<!-- rest of the page -->
</Layout>
This generates two pages:
/index.html(Polish,localeparam isundefined)/en/index.html(English,localeparam is'en')
The same pattern works for paginated routes. Note that InferGetStaticPropsType also infers the page prop that Astro’s paginate() injects automatically:
---
// 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
---
Pages with Different Slugs per Locale
Some pages need entirely different URLs per language. For example, your services page might live at /uslugi in Polish and /en/services in English. Since these are different slugs, the [...locale] rest parameter alone cannot handle them - you need separate page files.
First, define the mapping in PATHNAMES (already done in Step 1):
export const PATHNAMES = {
'/services': {
pl: '/uslugi',
en: '/services',
},
// ...
}
Then create two page files - one for each locale’s slug:
---
// src/pages/uslugi.astro (Polish version)
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}>
<!-- Polish services content -->
</Layout>
---
// src/pages/[...locale]/services.astro (English version)
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}>
<!-- English services content -->
</Layout>
In navigation, use getLocalizedRoute() to always point to the correct URL:
---
import { getLocalizedRoute } from '@i18n'
---
<a href={getLocalizedRoute('/services', locale)}>
{strings.nav.services}
</a>
The getAlternateRoute() call generates the correct hreflang URL for the other locale, and getLocalizedRoute() resolves the right path for any locale - all derived from the single PATHNAMES definition.
Step 6: Multilingual Content Collections
Blog posts are organized by locale in the file system:
src/content/posts/
├── en/
│ ├── deploying-astro-on-dokploy.mdx
│ └── my-programming-journey.mdx
└── pl/
├── wdrazanie-astro-na-dokploy.mdx
└── moja-droga-w-programowaniu.mdx
Each post has a lang field and an optional translationKey to link translations:
---
title: 'Moja droga w programowaniu'
lang: 'pl'
translationKey: 'my-programming-journey'
# ... other frontmatter
---
The content schema validates these fields:
// 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(),
}),
})
Filtering posts by locale is straightforward:
// 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())
Step 7: Linking Translated Blog Posts
When rendering a blog post, find its translation counterpart and pass the alternate URL for 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)
// Find the translated counterpart by 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 },
}
})
})
}
---
The alternateHref is then used in the layout to generate proper <link rel="alternate" hreflang="..."> tags.
Step 8: Language Switcher Component
The switcher uses hreflang links already present in the <head> to find the correct URL for the target language, and saves the preference via a server-side API:
---
// 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
// Use hreflang links for blog posts with different slugs
const hreflangLink = document.querySelector<HTMLLinkElement>(
`link[rel="alternate"][hreflang="${targetHreflang}"]`,
)
if (hreflangLink?.href) {
link.href = new URL(hreflangLink.href).pathname
}
// Save preference via server-side API (httpOnly cookie)
link.addEventListener('click', () => {
if (targetLocale) {
navigator.sendBeacon('/api/set-language', targetLocale)
}
})
}
}
initLanguageSwitcher()
document.addEventListener('astro:page-load', initLanguageSwitcher)
</script>
The language preference is stored as an httpOnly cookie via a server-side API endpoint, which is more secure than setting cookies client-side:
// 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 })
}
We use navigator.sendBeacon() in the click handler because it fires asynchronously without blocking navigation - the cookie is set server-side while the browser navigates to the new URL.
The key trick for blog posts: since translated posts have different slugs (like /blog/moja-droga/ vs /en/blog/my-journey/), the component reads the hreflang link from <head> to get the real translated URL instead of just swapping the locale prefix.
Caveat: Browser Back Button
There’s a subtle UX issue with the default <a> navigation - switching language pushes a new entry to the browser history. So when a user reads a blog post, switches to English, and hits the back button, they go back to the Polish version instead of the previous page.
The fix is to use window.location.replace() instead of normal navigation. This replaces the current history entry rather than adding a new one:
link.addEventListener('click', (e) => {
if (targetLocale) {
navigator.sendBeacon('/api/set-language', targetLocale)
}
e.preventDefault()
window.location.replace(link.href)
})
Now the back button takes the user to the actual previous page, regardless of how many times they switch languages.
Step 9: Server-Side Locale Detection
Instead of detecting the browser language client-side (which causes a visible redirect and hurts Lighthouse performance), we handle it server-side. The root / page is server-rendered and uses Astro’s built-in preferredLocale to check the Accept-Language header:
---
// 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}>
<!-- Polish homepage content -->
</Layout>
This approach is much better than the client-side alternative:
- No double page load - the redirect happens before any HTML is sent
- No Lighthouse penalty - the client-side approach cost ~1.5 seconds in redirect time
Astro.preferredLocaleproperly parsesAccept-Languagewith quality values (e.g.en-US,en;q=0.9,pl;q=0.8), unlike naivenavigator.languageparsing
The [...locale]/index.astro page still generates the prerendered /en/ page via getStaticPaths, but skips the default locale since / is now handled by the SSR index.astro. The SSR overhead is minimal - no database or API calls, just template rendering.
Step 10: SEO - Hreflang and Sitemap
Your layout should include hreflang links for every locale. Important: use BCP47 format (e.g. pl-PL, en-US) instead of bare language codes - this ensures consistency with the sitemap and better interpretation by Google:
---
// In Layout.astro <head>
---
{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]} />
For the sitemap, configure @astrojs/sitemap with i18n support. The sitemap plugin’s i18n option automatically adds hreflang links based on locale URL prefixes, but it does not generate x-default tags and cannot match pages with different slugs per language - /blog/moja-droga-w-programowaniu/ vs /en/blog/my-programming-journey/. Instead of manually maintaining a list of translated URL pairs, we use buildHreflangMap() which generates the full hreflang map (including x-default) directly from PATHNAMES - the single source of truth:
// 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
},
}),
],
})
Every entry in PATHNAMES is automatically included in the sitemap hreflang map - both shared paths (like /blog) and per-locale ones. The map also includes an x-default tag pointing to the default locale, which helps Google correctly understand the relationship between language versions. When you add a new translated page or post, you only update PATHNAMES - the sitemap picks it up with no extra configuration.
Summary
One config file defines all locale data. [...locale] rest params handle routing - undefined for default, locale string for others. Translation files are plain TypeScript objects with as const for type safety. Content collections use lang field for filtering and translationKey for linking translations. Hreflang links in <head> serve double duty - SEO and language switcher navigation. Browser detection runs once and respects the user’s cookie preference. PATHNAMES is the single source of truth for localized URLs - navigation, hreflang, and sitemap all derive from it.
The whole system is about 200 lines of utility code, fully type-safe, no external i18n libraries.
If you’re looking to self-host your Astro site after setting up i18n, check out my guide on deploying Astro 5 on Dokploy - the same site this i18n system runs on.