· astro / i18n / multilingual

Cách xây dựng một site Astro đa ngôn ngữ (EN + VI)

Xây dựng site Astro 5 hai locale: i18n routing sẵn có, content collections, language switcher, hreflang và Cloudflare Workers deploy. Không plugin nào.

Bởi

1.915 từ · 10 phút đọc

Astro đã hỗ trợ i18n routing ngay từ đầu kể từ v3.5.0. Bạn khai báo hai locale trong astro.config.mjs, tổ chức nội dung vào các thư mục en/vi/, rồi Astro xử lý việc tạo URL, fallback, và phát hiện locale. Không cần plugin bên thứ ba.

Hướng dẫn này áp dụng cho site ưu tiên tiếng Anh (URL sạch dạng /articles/slug) với tiếng Việt tại /vi/articles/slug. Cấu hình sử dụng Content Layer API của Astro 5, đã thay thế Collections từ v2. Nếu bạn vẫn đang dùng v4, hãy lưu ý các thay đổi migration — chúng là breaking changes.

Dành cho ai

Các developer TypeScript đang xây dựng một site nội dung nặng với hai locale. Cách thiết lập này có thể mở rộng thêm locale sau này; thêm locale thứ ba chỉ cần một thư mục mới và một entry trong mảng config. Nếu bạn cần translation memory, bản nháp có hỗ trợ máy, hay WYSIWYG editor, kiến trúc này vẫn có thể tích hợp được — nhưng những lớp đó không được đề cập ở đây.

Môi trường thử nghiệm

Astro 5.x (Content Layer API, ra mắt cuối 2024). Các tùy chọn Config API được ghim phiên bản từ Astro configuration reference chính thức. Đích deploy: Cloudflare Workers (static output, không dùng adapter).


Bước 1: Cấu hình i18n trong astro.config.mjs

Thêm block i18n. English là locale mặc định, nên các trang EN không có prefix. Các trang tiếng Việt sẽ có prefix /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 cho bạn /articles/slug thay vì /en/articles/slug. Tốt cho SEO khi English là đối tượng chính.

fallbackType: 'rewrite' có nghĩa là khi một trang /vi/ chưa có, Astro sẽ tự serve phiên bản tiếng Anh tại cùng URL thay vì trả về 404. Tính năng này yêu cầu Astro 4.15.0 trở lên. Bỏ qua nếu bạn muốn 404 cho các bản dịch còn thiếu.

fallback: { vi: 'en' } kích hoạt rule rewrite ở trên — nó báo cho Astro biết locale nào sẽ được dùng làm fallback.

Lỗi thường gặp: nếu bạn đặt prefixDefaultLocale: false nhưng sau đó tạo thư mục src/pages/en/, Astro sẽ coi en là một sub-path chứ không phải route của locale mặc định. Hãy giữ các trang EN tại src/pages/ (không có thư mục con theo locale).


Bước 2: Định nghĩa content collection

Astro v5 đã chuyển config collection ra khỏi src/content/. Đặt nó tại src/content.config.ts (không phải src/content/config.ts):

// src/content.config.ts  ← KHÔNG phải 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 };

Trường slug là định danh chuẩn — cùng một chuỗi trong cả hai file locale. Astro v5 đã bỏ tính năng tự động suy ra slug từ tên file; bạn phải đặt tường minh.

Lỗi thường gặp: trong v5, đường dẫn cũ src/content/config.ts bị bỏ qua không báo lỗi. Nếu types của bạn ngừng hoạt động, hãy kiểm tra lại rằng file đang ở src/content.config.ts.


Bước 3: Tổ chức các file nội dung

Tạo cấu trúc thư mục tương tự cho từng locale:

src/content/articles/
├── en/
│   └── getting-started.md
└── vi/
    └── getting-started.md

Frontmatter EN:

---
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"]
---

Frontmatter VI (cùng slug, khác 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 không bắt buộc slug phải khớp giữa các locale. Nếu file VI bị thiếu, fallbackType: 'rewrite' sẽ xử lý. Để kiểm tra cứng lúc build, hãy query cả hai collection rồi so sánh tập hợp slug.

Lỗi thường gặp: nếu bạn query theo id thay vì data.slug, bạn sẽ nhận được en/getting-startedvi/getting-started như hai entry riêng biệt trông không liên quan. Luôn chuẩn hóa qua data.slug khi cần ghép cặp bản dịch.


Bước 4: Tạo các route theo locale

Hai page, một cho mỗi locale. EN ở cấp root; VI nằm trong 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() được import từ astro:content trong v5. Trong v4 là entry.render() — một breaking change nữa.

Lỗi thường gặp: nếu bạn quên lọc theo locale trong getStaticPaths, cả entry EN lẫn VI sẽ cố tạo route cho cùng slug và Astro sẽ báo lỗi duplicate params.


Bước 5: Thêm language switcher

Một component HTML thuần. Không cần JavaScript.

// 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') trả về /articles/getting-started — không có prefix, vì EN là locale mặc định với prefixDefaultLocale: false.

getRelativeLocaleUrl('vi', 'articles/getting-started') trả về /vi/articles/getting-started.

Dùng nó trong article page:

<LocaleSwitcher currentLocale={entry.data.locale} currentSlug={entry.data.slug} />

Lỗi thường gặp: nếu getRelativeLocaleUrl trả về URL không như mong đợi, hãy kiểm tra lại i18n.defaultLocalerouting.prefixDefaultLocale trong config — helper này tuân theo các giá trị đó lúc build.


Bước 6: Cấu hình hreflang cho SEO

Mỗi biến thể trang phải liệt kê tất cả các biến thể khác, bao gồm chính nó. Thiếu link tự tham chiếu là lỗi phổ biến mà Google Search Console sẽ cảnh báo.

// 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, tất cả đều bắt buộc -->
  <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>

Các quy tắc từ Google hreflang spec:

  • Chỉ dùng URL tuyệt đối. //yoursite.com/... (protocol-relative) là không hợp lệ.
  • Language code phải theo ISO 639-1: envi, không phải EN hay VN.
  • x-default nên trỏ đến locale chính (EN ở đây) hoặc trang chọn ngôn ngữ.
  • Nếu bản dịch VI chưa tồn tại và fallbackType: 'rewrite' đang serve nội dung EN tại URL VI, vẫn giữ hreflang VI — URL đó hợp lệ và Google sẽ xử lý được rewrite.

Lỗi thường gặp: nếu bạn tạo hreflangUrls động từ một danh sách nhưng quên đưa locale của trang hiện tại vào danh sách, trang sẽ không có thẻ alternate tự tham chiếu. Google vẫn chấp nhận, nhưng Search Console sẽ cảnh báo về hreflang set không đầy đủ.


Bước 7: Deploy lên Cloudflare Workers

Static output của Astro không cần adapter. Build ra dist/ và deploy bằng 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" serve dist/404.html cho các route không khớp. Điều này quan trọng khi một bản dịch VI bị thiếu và fallbackType: 'rewrite' chưa được cấu hình — nếu không có trang 404, Cloudflare sẽ trả về response rỗng.

Cloudflare hiện khuyến nghị dùng Workers thay vì Pages cho các dự án mới. Nếu bạn đã tạo Pages project trước đây, nó vẫn hoạt động — Pages là một con đường cũ hơn, không phải bị deprecated.

Để xem hướng dẫn thiết lập Cloudflare Pages chi tiết có hỗ trợ monorepo và pnpm, xem Cách deploy site Astro lên Cloudflare Pages.

Lỗi thường gặp: Auto Minify của Cloudflare (Speed → Optimization trong dashboard) có thể thay đổi thứ tự attribute trên HTML tags, làm hỏng các hydration marker của Astro ở phía client. Hãy tắt tính năng này.


Giữ đồng bộ bản dịch

Astro sẽ không báo cho bạn biết khi có bài EN chưa có bản VI. Nên thêm một kiểm tra lúc build vào 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);
}

Chạy script này trước khi deploy. Nếu fallbackType: 'rewrite' đang bật, các bản dịch còn thiếu sẽ không trả về 404 — nhưng bạn vẫn nên biết những trang nào đang serve nội dung fallback.


Nhận xét

Dùng i18n tích hợp sẵn của Astro cho site hai locale. Config tối giản, URL generation có kiểu dữ liệu rõ ràng, và static output có nghĩa là mỗi route theo locale là một file HTML riêng biệt — không có server-side locale detection lúc runtime.

Hai điểm còn chưa hoàn thiện: không có cơ chế kiểm tra slug phải khớp giữa các locale, và không có hệ thống dịch UI string tích hợp. Vấn đề đầu có thể giải quyết bằng script kiểm tra ở trên. Với UI strings (nhãn nav, nội dung nút), một dictionary src/i18n/ui.ts với locale keys là pattern tiêu chuẩn — Astro i18n recipe có ví dụ cụ thể.

Nếu bạn đang so sánh Astro với các SSG khác, Astro vs Eleventy phân tích trade-off về tốc độ build, còn Next.js vs Astro giải thích khi nào nên dùng app framework thay vì tool tập trung vào nội dung.


Tài liệu tham khảo