· astro / i18n / multilingual
How to build a multilingual Astro site (EN + VI)
Build an EN+VI Astro 5 site using built-in i18n routing: content collections, locale pages, language switcher, hreflang, and Cloudflare Workers deploy.
By Ethan
1,667 words · 9 min read
Astro has had first-class i18n routing since v3.5.0. You declare two locales in astro.config.mjs, organize your content into en/ and vi/ folders, and Astro handles URL generation, fallbacks, and locale detection. No third-party plugin required.
This guide covers an English-primary site (clean /articles/slug URLs) with Vietnamese at /vi/articles/slug. It uses Astro 5’s Content Layer API, which replaced v2 Collections. If you’re still on v4, note the migration differences — they are breaking.
Who this is for
TypeScript developers shipping a content-heavy site with two locales. The setup here scales to more locales later; adding a third means one new folder and one new entry in the config array. If you need live translation memory, machine-assisted drafts, or a WYSIWYG editor, this architecture can host that — but those layers aren’t covered here.
What we tested
Astro 5.x (Content Layer API, released late 2024). Config API options were version-pinned from the official Astro configuration reference. Deployment target: Cloudflare Workers (static output, no adapter).
Step 1: Configure i18n in astro.config.mjs
Add the i18n block. English is the default locale, so EN pages get no prefix. Vietnamese pages get /vi/.
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
site: 'https://yoursite.com',
i18n: {
locales: ['en', 'vi'],
defaultLocale: 'en',
routing: {
prefixDefaultLocale: false,
redirectToDefaultLocale: false,
fallbackType: 'rewrite',
},
fallback: {
vi: 'en',
},
},
});
prefixDefaultLocale: false gives you /articles/slug instead of /en/articles/slug. Good for SEO when English is your primary audience.
fallbackType: 'rewrite' means a missing /vi/ page silently serves the English version at the same URL instead of returning 404. This requires Astro 4.15.0 or later. Drop it if you’d rather 404 missing translations.
fallback: { vi: 'en' } enables the rewrite rule above — it tells Astro which locale to fall back to.
Failure mode: if you set prefixDefaultLocale: false but then create a src/pages/en/ folder, Astro treats en as a sub-path, not as a default locale route. Keep EN pages at src/pages/ (no locale subfolder).
Step 2: Define the content collection
Astro v5 moved collection config out of src/content/. Place it at src/content.config.ts (not src/content/config.ts):
// src/content.config.ts ← NOT src/content/config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const articles = defineCollection({
loader: glob({
pattern: '**/*.{md,mdx}',
base: './src/content/articles',
}),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
locale: z.enum(['en', 'vi']),
slug: z.string(),
draft: z.boolean().default(false),
tags: z.array(z.string()).optional(),
}),
});
export const collections = { articles };
The slug field is a canonical identifier — the same string in both locale files. Astro v5 dropped the automatic slug inference from filename; you set it explicitly.
Failure mode: in v5, the old src/content/config.ts path is silently ignored. If your types stop resolving, check that the file is at src/content.config.ts.
Step 3: Organize content files
Mirror the folder structure for each locale:
src/content/articles/
├── en/
│ └── getting-started.md
└── vi/
└── getting-started.md
EN frontmatter:
---
title: "Getting started with Astro"
description: "A quick intro to Astro's project structure."
pubDate: 2026-05-22
locale: en
slug: getting-started
draft: false
tags: ["astro", "tutorial"]
---
VI frontmatter (same slug, different locale):
---
title: "Bắt đầu với Astro"
description: "Giới thiệu nhanh về cấu trúc dự án Astro."
pubDate: 2026-05-22
locale: vi
slug: getting-started
draft: false
tags: ["astro", "hướng dẫn"]
---
Astro has no enforcement of slug parity across locales. If a VI file is missing, fallbackType: 'rewrite' catches it. For a harder check at build time, query both collections and diff the slug sets.
Failure mode: if you query by id instead of data.slug, you’ll get en/getting-started and vi/getting-started as separate entries that look unrelated. Always normalize through data.slug when you need to pair translations.
Step 4: Create locale-aware page routes
Two pages, one per locale. EN lives at root level; VI lives inside src/pages/vi/.
// src/pages/articles/[slug].astro
---
import { getCollection, render } from 'astro:content';
export async function getStaticPaths() {
const articles = await getCollection('articles', ({ data }) =>
data.locale === 'en' && !data.draft
);
return articles.map(entry => ({
params: { slug: entry.data.slug },
props: { entry },
}));
}
const { entry } = Astro.props;
const { Content } = await render(entry);
---
<article>
<h1>{entry.data.title}</h1>
<Content />
</article>
// src/pages/vi/articles/[slug].astro
---
import { getCollection, render } from 'astro:content';
export async function getStaticPaths() {
const articles = await getCollection('articles', ({ data }) =>
data.locale === 'vi' && !data.draft
);
return articles.map(entry => ({
params: { slug: entry.data.slug },
props: { entry },
}));
}
const { entry } = Astro.props;
const { Content } = await render(entry);
---
<article>
<h1>{entry.data.title}</h1>
<Content />
</article>
render() is imported from astro:content in v5. In v4 it was entry.render() — another breaking change.
Failure mode: if you forget to filter by locale in getStaticPaths, both EN and VI entries will try to generate routes for the same slug and Astro will throw a duplicate params error.
Step 5: Add a locale switcher
A pure HTML component. No JavaScript required.
// src/components/LocaleSwitcher.astro
---
import { getRelativeLocaleUrl } from 'astro:i18n';
interface Props {
currentLocale: string;
currentSlug: string;
}
const { currentLocale, currentSlug } = Astro.props;
const locales = [
{ code: 'en', label: 'English' },
{ code: 'vi', label: 'Tiếng Việt' },
];
---
<nav aria-label="Language switcher">
{locales.map(({ code, label }) => (
<a
href={getRelativeLocaleUrl(code, `articles/${currentSlug}`)}
aria-current={code === currentLocale ? 'page' : undefined}
hreflang={code}
>
{label}
</a>
))}
</nav>
getRelativeLocaleUrl('en', 'articles/getting-started') returns /articles/getting-started — no prefix, because EN is the default locale with prefixDefaultLocale: false.
getRelativeLocaleUrl('vi', 'articles/getting-started') returns /vi/articles/getting-started.
Use it in your article page:
<LocaleSwitcher currentLocale={entry.data.locale} currentSlug={entry.data.slug} />
Failure mode: if getRelativeLocaleUrl returns an unexpected URL, confirm your i18n.defaultLocale and routing.prefixDefaultLocale settings match what you expect — the helper respects those values at build time.
Step 6: Wire up hreflang for SEO
Every page variant must list all other variants, including itself. Missing the self-referencing link is a common mistake that Google’s Search Console flags.
// src/layouts/ArticleLayout.astro
---
interface Props {
slug: string;
locale: string;
title: string;
}
const { slug, locale, title } = Astro.props;
const siteUrl = 'https://yoursite.com';
const hreflangUrls = {
en: `${siteUrl}/articles/${slug}`,
vi: `${siteUrl}/vi/articles/${slug}`,
};
---
<html lang={locale}>
<head>
<meta charset="utf-8" />
<title>{title}</title>
<!-- Self + alternate + x-default, all required -->
<link rel="alternate" hreflang="en" href={hreflangUrls.en} />
<link rel="alternate" hreflang="vi" href={hreflangUrls.vi} />
<link rel="alternate" hreflang="x-default" href={hreflangUrls.en} />
</head>
<body>
<slot />
</body>
</html>
Rules from the Google hreflang spec:
- Absolute URLs only.
//yoursite.com/...(protocol-relative) is invalid. - Language codes must be ISO 639-1:
enandvi, notENorVN. x-defaultshould point to your primary locale (EN here) or a locale selector.- If the VI translation doesn’t exist and
fallbackType: 'rewrite'is serving EN content at the VI URL, still include the VI hreflang — the URL is valid and Google will resolve the rewrite.
Failure mode: if you template hreflangUrls dynamically from a list but forget to include the current page’s own locale in that list, the page won’t have a self-referencing alternate tag. Google will accept it, but Search Console will warn about incomplete hreflang sets.
Step 7: Deploy to Cloudflare Workers
Static Astro output needs no adapter. Build to dist/ and deploy with Wrangler.
// wrangler.jsonc
{
"name": "yoursite",
"compatibility_date": "2024-09-23",
"assets": {
"directory": "./dist",
"not_found_handling": "404-page"
}
}
npx astro build
npx wrangler deploy
not_found_handling: "404-page" serves dist/404.html for unmatched routes. This matters when a VI translation is missing and fallbackType: 'rewrite' hasn’t been configured — without the 404 page, Cloudflare returns an empty response.
Cloudflare now recommends Workers over Pages for new projects. If you created a Pages project before switching, it still works — Pages is a legacy path, not a deprecated one.
For a step-by-step Cloudflare Pages setup with monorepo and pnpm support, see How to Deploy an Astro Site on Cloudflare Pages.
Failure mode: Cloudflare’s Auto Minify (Speed → Optimization in the dashboard) can rewrite attribute order on HTML tags, which breaks Astro’s client-side hydration markers. Turn it off.
Keeping translations in sync
Astro won’t tell you when an EN article exists without a VI counterpart. A build-time check is worth adding to CI:
// scripts/check-slug-parity.ts
import { getCollection } from 'astro:content';
const enArticles = await getCollection('articles', d => d.data.locale === 'en');
const viArticles = await getCollection('articles', d => d.data.locale === 'vi');
const enSlugs = new Set(enArticles.map(a => a.data.slug));
const viSlugs = new Set(viArticles.map(a => a.data.slug));
const missingVi = [...enSlugs].filter(s => !viSlugs.has(s));
if (missingVi.length) {
console.warn('EN articles missing VI translation:', missingVi);
}
Run this before deploy. If fallbackType: 'rewrite' is on, missing translations won’t 404 — but you still want to know which pages are serving fallback content.
Verdict
Use Astro’s built-in i18n for a two-locale site. The config is minimal, the URL generation is typed, and static output means every locale route is a physical HTML file — no server-side locale detection at runtime.
The two rough edges: no enforcement of slug parity across locales, and no built-in UI string translation system. The parity problem is solvable with the check script above. For UI strings (nav labels, button text), a src/i18n/ui.ts dictionary with locale keys is the standard pattern — the Astro i18n recipe shows the shape.
If you’re still choosing between Astro and another SSG, Astro vs Eleventy covers the build-speed tradeoffs, and Next.js vs Astro covers when to pick an app framework instead of a content-first tool.
References
- Astro internationalization guide — routing config, fallback options, locale helpers
- Astro content collections — Content Layer API, v5 schema changes
- Astro v5 upgrade guide — breaking changes list including
content.config.tspath andrender()import - Astro configuration reference — version history for each i18n option
- Astro i18n recipe — locale switcher, UI string dictionary pattern
- Cloudflare deploy guide — Workers vs Pages, wrangler.jsonc setup
- Google hreflang spec — hreflang requirements and common mistakes