· 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

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: en and vi, not EN or VN.
  • x-default should 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