10 min read

How to Build a Multilingual Website with Astro 5: Complete Guide

Step-by-step guide to building a multilingual website with Astro 5. File-based routing, content collections, translation strings, and SEO with real code examples.

astro i18n tutorial
Table of Contents

Most i18n tutorials give you a toy example with two pages and call it a day. What follows covers the full picture: routing, translation strings, content collections, dynamic blog routes, and SEO, all based on a real production site.

By the end, you’ll have a multilingual Astro site where:

  • The default language (English) lives at /about, /blog, etc.
  • Additional languages use a prefix: /id/about, /id/blog, etc.
  • Content collections serve locale-specific blog posts
  • Translation strings handle all UI text
  • Search engines get proper hreflang tags and a localized sitemap

Let’s build it.

What we’re building

The architecture is straightforward:

  • English is the default locale (no URL prefix)
  • Indonesian uses the /id/ prefix
  • Pages are duplicated per locale using file-based routing
  • Blog content lives in locale-specific subfolders (blog/en/, blog/id/)
  • A single translation file handles all UI strings

Every code snippet below comes from the actual codebase running this site.

Step 1: Astro i18n configuration

Astro 5 has built-in i18n support. Start in astro.config.mjs:

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

export default defineConfig({
  site: 'https://yoursite.com',
  i18n: {
    defaultLocale: 'en',
    locales: ['en', 'id'],
    routing: {
      prefixDefaultLocale: false,
    },
  },
})

Two key decisions here:

  • prefixDefaultLocale: false: English pages don’t get an /en/ prefix. /about is English, /id/about is Indonesian. Keeps URLs clean for your primary audience.
  • locales: List every locale you support. Astro uses this array for routing and for setting Astro.currentLocale.

With this config, Astro automatically detects the locale from the URL path. Pages under /id/ get 'id', everything else gets 'en'.

Step 2: File-based routing

Astro doesn’t auto-generate locale variants. You create them explicitly with file structure:

src/pages/
├── index.astro          # / (English)
├── about.astro          # /about (English)
├── blog/
│   ├── index.astro      # /blog (English)
│   └── [slug].astro     # /blog/:slug (English)
├── id/
│   ├── index.astro      # /id (Indonesian)
│   ├── about.astro      # /id/about (Indonesian)
│   └── blog/
│       ├── index.astro  # /id/blog (Indonesian)
│       └── [slug].astro # /id/blog/:slug (Indonesian)

Each locale gets its own page files. Might seem redundant, but it gives you full control. The English and Indonesian versions of a page can have different layouts, different sections, or different content, not just translated strings.

In practice, most pages share the same template and just swap text via the translation system. But you’re never locked in.

Step 3: Translation strings

Create a central translation file. Here’s the pattern:

// src/i18n/ui.ts
import { getLocalePath, type Locale } from './utils'

const ui = {
  id: {
    'nav.home': 'Beranda',
    'nav.portfolio': 'Portfolio',
    'nav.services': 'Layanan',
    'nav.about': 'Tentang',
    'nav.contact': 'Kontak',
    'hero.headline': 'Saya Bantu Website Bisnis Anda Jadi Nyata',
    // ... more keys
  },
  en: {
    'nav.home': 'Home',
    'nav.portfolio': 'Portfolio',
    'nav.services': 'Services',
    'nav.about': 'About',
    'nav.contact': 'Contact',
    'hero.headline': 'I Build Websites That Actually Work',
    // ... more keys
  },
} as const

type TranslationKey = keyof (typeof ui)['en']

export function t(key: TranslationKey, locale: Locale = 'en'): string {
  return ui[locale][key] ?? ui.en[key] ?? key
}

The t() function takes a key and a locale, returns the translation. The fallback chain goes: requested locale → English → raw key. So you can add new keys to English first and they won’t break other locales.

Usage in any .astro component:

---
import { t } from '@/i18n/ui'
import { getLocale } from '@/i18n/utils'

const locale = getLocale(Astro.currentLocale)
---

<h1>{t('hero.headline', locale)}</h1>
<nav>
  <a href="/">{t('nav.home', locale)}</a>
  <a href="/about">{t('nav.about', locale)}</a>
</nav>

Why not a framework like astro-i18next?

For a site with 2 locales, a framework is overhead. The t() function above is ~5 lines and covers every case. You get full TypeScript autocompletion on translation keys, no runtime dependencies, and no build plugins. If you scale to 10+ locales, consider a framework. For 2-3, this pattern is enough.

Step 4: Locale utilities

You need three helper functions. Put them in a utils file:

// src/i18n/utils.ts
export type Locale = 'id' | 'en'
export const defaultLocale: Locale = 'en'
export const locales: Locale[] = ['en', 'id']

/**
 * Get locale from Astro.currentLocale, falling back to default
 */
export function getLocale(currentLocale: string | undefined): Locale {
  if (currentLocale && locales.includes(currentLocale as Locale)) {
    return currentLocale as Locale
  }
  return defaultLocale
}

/**
 * Get the path for a given locale
 * Default locale (en) has no prefix: /about
 * Indonesian has /id/ prefix: /id/about
 */
export function getLocalePath(path: string, locale: Locale): string {
  const cleanPath = path.startsWith('/') ? path : `/${path}`
  if (locale === defaultLocale) return cleanPath
  return `/${locale}${cleanPath}`
}

/**
 * Get the alternate locale path (for language switcher)
 */
export function getAlternatePath(
  currentPath: string,
  targetLocale: Locale
): string {
  let basePath = currentPath
  for (const loc of locales) {
    if (loc !== defaultLocale && currentPath.startsWith(`/${loc}/`)) {
      basePath = currentPath.slice(loc.length + 1)
      break
    }
    if (loc !== defaultLocale && currentPath === `/${loc}`) {
      basePath = '/'
      break
    }
  }
  return getLocalePath(basePath, targetLocale)
}

What each function does:

  • getLocale() safely converts Astro.currentLocale (which is string | undefined) into your Locale type. Handles edge cases where the value is undefined.
  • getLocalePath() generates locale-aware URLs. Pass ('/about', 'en')/about. Pass ('/about', 'id')/id/about.
  • getAlternatePath() powers the language switcher. Takes the current URL and a target locale, strips any existing locale prefix, and rebuilds the path. /id/about + 'en'/about.

These three functions show up everywhere: navigation links, language switcher, breadcrumbs, canonical URLs, and hreflang tags.

Step 5: Multilingual content collections

For blog posts (or any content collection), organize files by locale:

src/content/blog/
├── en/
│   ├── getting-started.mdx
│   ├── performance-tips.mdx
│   └── astro-multilingual-guide.mdx
└── id/
    ├── getting-started.mdx
    └── performance-tips.mdx

The collection schema in src/content/config.ts doesn’t need a locale field. The locale is encoded in the folder structure:

// src/content/config.ts
import { defineCollection, z } from 'astro:content'

const blog = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    publishedAt: z.coerce.date(),
    updatedAt: z.coerce.date().optional(),
    tags: z.array(z.string()).default([]),
    featured: z.boolean().default(false),
    draft: z.boolean().default(false),
    image: z.string().optional(),
  }),
})

export const collections = { blog }

Astro generates slugs that include the folder path: a file at en/getting-started.mdx gets slug en/getting-started. We’ll filter on that in the next step.

Do I need matching slugs across locales?

Not necessarily. Using the same filename across locales (en/getting-started.mdx and id/getting-started.mdx) makes it easier to link alternate versions. But it’s not required. Some posts might only exist in one language, and that’s fine.

Step 6: Dynamic blog routes

Here’s where the locale-based folder structure pays off. Each locale’s [slug].astro filters content by its prefix.

English blog route (src/pages/blog/[slug].astro):

---
import { getCollection } from 'astro:content'

export async function getStaticPaths() {
  const posts = await getCollection('blog', ({ slug, data }) =>
    slug.startsWith('en/') && !data.draft
  )
  return posts.map((post) => ({
    params: { slug: post.slug.replace(/^en\//, '') },
    props: { post },
  }))
}

const { post } = Astro.props
const { Content } = await post.render()
---

<article>
  <h1>{post.data.title}</h1>
  <Content />
</article>

Indonesian blog route (src/pages/id/blog/[slug].astro):

---
import { getCollection } from 'astro:content'

export async function getStaticPaths() {
  const posts = await getCollection('blog', ({ slug, data }) =>
    slug.startsWith('id/') && !data.draft
  )
  return posts.map((post) => ({
    params: { slug: post.slug.replace(/^id\//, '') },
    props: { post },
  }))
}

const { post } = Astro.props
const { Content } = await post.render()
---

<article>
  <h1>{post.data.title}</h1>
  <Content />
</article>

The pattern:

  1. Filter: getCollection() takes a filter function. slug.startsWith('en/') grabs only English posts.
  2. Strip prefix: The slug en/getting-started becomes the URL param getting-started, so the final URL is /blog/getting-started.
  3. Same for ID: The Indonesian route does the same with id/ prefix, producing /id/blog/getting-started.

The blog listing page uses the same filter:

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

const allPosts = await getCollection('blog', ({ slug, data }) =>
  slug.startsWith('en/') && !data.draft
)

const sortedPosts = allPosts.sort(
  (a, b) => b.data.publishedAt.valueOf() - a.data.publishedAt.valueOf()
)
---

Posts in blog/en/ only show up at /blog, and posts in blog/id/ only show up at /id/blog. A post that only exists in English won’t have an Indonesian version. No errors, no missing pages.

Step 7: SEO for multilingual sites

Most i18n implementations skip this part or get it wrong. Search engines need explicit signals about your locale structure.

hreflang tags

Add these to your <head> in the base layout:

---
// In your BaseLayout.astro
import { locales, getAlternatePath } from '@/i18n/utils'

const currentPath = Astro.url.pathname
---

<head>
  {locales.map((loc) => (
    <link
      rel="alternate"
      hreflang={loc}
      href={new URL(getAlternatePath(currentPath, loc), Astro.site)}
    />
  ))}
  <link
    rel="alternate"
    hreflang="x-default"
    href={new URL(getAlternatePath(currentPath, 'en'), Astro.site)}
  />
</head>

These tags tell Google: “here’s the English version, here’s the Indonesian version, and English is the default.” Every page gets them automatically.

Sitemap

Astro’s sitemap integration has built-in i18n support:

// astro.config.mjs
import sitemap from '@astrojs/sitemap'

export default defineConfig({
  integrations: [
    sitemap({
      i18n: {
        defaultLocale: 'en',
        locales: {
          en: 'en',
          id: 'id',
        },
      },
    }),
  ],
})

The generated sitemap includes xhtml:link alternates for each locale, matching what the hreflang tags declare.

Canonical URLs

Set the canonical URL on every page to avoid duplicate content issues:

<link rel="canonical" href={new URL(Astro.url.pathname, Astro.site)} />

For locale variants, the canonical should point to itself (not to the other locale). Each locale version is a legitimate page, not a duplicate.

Common pitfalls

After building this system, here’s what tripped me up:

1. Forgetting prefixDefaultLocale: false implications

With this setting, Astro.currentLocale is undefined for some edge cases (like API routes or middleware). Always use the getLocale() wrapper instead of reading Astro.currentLocale directly.

2. Content collection slugs include the folder path

If your file is at src/content/blog/en/my-post.mdx, the slug is en/my-post, not my-post. You need to strip the locale prefix when generating URL params, and add the filter in getStaticPaths(). Forget either one and you’ll get 404s or mixed-locale pages.

3. Hardcoded paths in components

It’s tempting to write <a href="/about">. Breaks immediately for non-default locales. Always use getLocalePath('/about', locale) so Indonesian users get /id/about.

4. Missing translation keys

When you add a new UI string to English and forget the Indonesian translation, the t() function falls back to the English value silently. By design, it prevents build errors. But you should periodically audit for missing keys. TypeScript helps here: if you add a key to one locale’s type and not the other, the compiler warns you.

5. Not all content needs both locales

Technical blog posts might only make sense in English. Marketing pages might only make sense in the local language. The system handles this naturally. Don’t create the file in the other locale’s folder and you won’t get errors or empty pages.

The result

This is the i18n system running on this site right now. Every page serves both English and Indonesian, with proper SEO signals, shared components, and zero JavaScript overhead for the translation layer.

What makes it work: Astro’s file-based routing and content collections make multilingual sites straightforward without heavy frameworks. The entire i18n layer is around 50 lines of TypeScript (the utils file and the t() function).


Need a multilingual website built with this approach? I build fast, SEO-optimized websites for businesses. Get in touch for a free consultation.

Need a Website?

Tell me what you need. I'll tell you if I can help, how long it takes, and what it costs. No commitment.