Astro Content Collections cho blog đa ngôn ngữ | Schema MDX + Zod 2026
Blog đa ngôn ngữ trên Astro: một collection (thư mục) cho mỗi locale, Zod schema cho frontmatter MDX, getCollection("blog-vi") cho route /vi/blog. Dùng translationOf để nối bản JA, noIndex để ẩn stub mỏng. Thiếu field / sai kiểu = fail lúc build — đó là điểm mạnh, không phải phiền toái.
Freelance và team outsourcing Việt Nam hay ship portfolio hoặc docs JA + EN + VI trên cùng repo Astro. Viết Markdown thì dễ — nhưng khi thiếu description, tags thành string thay vì array, hoặc ngày "2026/07/19" lẫn "2026-07-19", lỗi chỉ lộ sau khi deploy. Content Collections đưa validation vào build time.
Bài này đi sâu góc blog đa ngôn ngữ + schema MDX, không dừng ở hello-world một collection.
Bài viết này giúp bạn
- Hiểu Content Collections và vì sao hợp MDX blog
- Chọn nhiều collection theo locale vs một collection +
locale - Viết Zod schema thực tế:
pubDate,faq,translationOf,noIndex - List / detail pages theo từng ngôn ngữ
- Tránh 5 lỗi build hay gặp khi scale số bài
Content Collections là gì?
Content Collections là lớp của Astro để:
- Đặt file Markdown/MDX trong
src/content/<tên-collection>/ - Khai báo Zod schema trong
src/content/config.ts - Lấy dữ liệu typed qua
getCollection/getEntry
Frontmatter không còn là “object tùy ý”. Sai schema → build fail, trước khi user thấy trang trống.
Với blog đa ngôn ngữ, bạn thường có:
| Thư mục | Collection | Route |
|---|---|---|
src/content/blog/ | blog | /blog/... |
src/content/blog-en/ | blog-en | /en/blog/... |
src/content/blog-vi/ | blog-vi | /vi/blog/... |
Mỗi locale một collection → schema có thể khác (VI cần translationOf, JA có thể không).
Một collection + locale vs nhiều collection
| Tiêu chí | Một collection + field locale | Nhiều collection (blog / blog-en / blog-vi) |
|---|---|---|
| Thư mục | Trộn file hoặc subfolder thủ công | Tách rõ theo ngôn ngữ |
| Schema | Một schema chung — field locale bắt buộc | Schema riêng: VI có noIndex, EN có redditAngle… |
| Routing | Filter getCollection rồi map path | getCollection đúng locale trong từng pages/vi, pages/en |
| Dịch song song | Dễ merge nhầm file cùng slug | Slug trùng giữa locale được (file khác folder) |
| Phù hợp khi | 2 ngôn ngữ, schema gần giống hệt | 3+ locale, schema/CTA khác nhau theo thị trường |
Gợi ý thực dụng: Portfolio / docs JA·EN·VI → nhiều collection. App content nhỏ 2 ngôn ngữ, cùng layout → một collection + locale cũng ổn.
Schema Zod cho MDX đa ngôn ngữ
src/content/config.ts (rút gọn theo pattern thực tế):
import { defineCollection, z } from "astro:content";
const blog = defineCollection({
type: "content",
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
tags: z.array(z.string()).default([]),
slug: z.string().optional(),
faq: z
.array(z.object({ question: z.string(), answer: z.string() }))
.optional(),
}),
});
const blogVi = defineCollection({
type: "content",
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
tags: z.array(z.string()).default([]),
/** Slug bài JA để hreflang / liên kết bản gốc */
translationOf: z.string().optional(),
relatedTool: z.string().optional(),
/** Ẩn stub mỏng khỏi index cho đến khi viết đủ chất lượng */
noIndex: z.boolean().optional(),
faq: z
.array(z.object({ question: z.string(), answer: z.string() }))
.optional(),
howTo: z
.object({
name: z.string(),
steps: z.array(z.string()),
totalTime: z.string().optional(),
})
.optional(),
}),
});
export const collections = {
blog,
"blog-en": /* tương tự blogVi */,
"blog-vi": blogVi,
};
Vì sao z.coerce.date()?
Trong YAML frontmatter, ngày là string. Không coerce thì entry.data.pubDate không có .getTime(). Sort list theo ngày mới nhất sẽ crash hoặc so sánh sai.
translationOf và noIndex
translationOf: slug bài tiếng Nhật (hoặc bản gốc) — dùng cho hreflang / “bản gốc”.noIndex: stub máy dịch hoặc bài chưa đạt chất lượng thị trường VI — ẩn khỏi search cho đến khi rewrite.
Team hay viết VI stub trước để giữ URL, rồi nâng dần. Schema cho phép noIndex optional; quy trình nội bộ quyết định khi nào gỡ.
Khi thiết kế faq / howTo, hãy paste object mẫu vào JSON Formatter để chắc cấu trúc trước khi chạy astro build. Zod fail message đôi khi dài — object sạch giúp đoán field sai nhanh hơn.
Viết bài MDX
src/content/blog-vi/hello-i18n.mdx:
---
title: "Xin chào Content Collections"
description: "Bài mẫu blog VI với schema typed."
pubDate: 2026-07-19
tags: ["Astro", "MDX"]
translationOf: "hello-i18n"
faq:
- question: "Cần tài khoản để đọc blog?"
answer: "Không — đây là static site."
---
Nội dung tiếng Việt ở đây.
Lưu ý tiếng Việt trong frontmatter/body: encoding UTF-8. Repo và editor phải UTF-8 — tránh lưu ANSI rồi title bị mojibake lúc build CI Windows/Linux khác nhau.
List và detail theo locale
List — src/pages/vi/blog/index.astro
---
import { getCollection } from "astro:content";
const entries = await getCollection("blog-vi");
const sorted = entries
.filter((e) => !e.data.noIndex)
.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());
---
<ul>
{sorted.map((entry) => (
<li>
<a href={`/vi/blog/${entry.slug}/`}>{entry.data.title}</a>
<time>{entry.data.pubDate.toLocaleDateString("vi-VN")}</time>
</li>
))}
</ul>
Filter noIndex trên list (và sitemap) nếu bạn dùng cờ đó. Detail vẫn generate được để preview nội bộ — tùy product.
Detail — src/pages/vi/blog/[...slug].astro
---
import { getCollection } from "astro:content";
import { mdxComponents } from "@/components/blog/mdx-components";
export async function getStaticPaths() {
const entries = await getCollection("blog-vi");
return entries.map((entry) => ({
params: { slug: entry.slug },
props: { entry },
}));
}
const { entry } = Astro.props;
const { Content } = await entry.render();
---
<article>
<h1>{entry.data.title}</h1>
<time datetime={entry.data.pubDate.toISOString()}>
{entry.data.pubDate.toLocaleDateString("vi-VN", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
<Content components={mdxComponents} />
</article>
entry.slug lấy từ tên file (hello-i18n.mdx → hello-i18n), trừ khi bạn override bằng field slug trong schema.
MDX components: Callout, ComparisonTable chỉ render khi truyền components={mdxComponents}. Quên bước này → MDX báo unknown component.
Case study: scale 3 locale trong một sprint
Bối cảnh: Team 4 người (1 JA, 1 EN, 2 VI freelance) giao portfolio blog trong 2 tuần.
- Tuần 1: Schema tối thiểu chung (
title,description,pubDate,tags). Ship ~30 bài JA. - Giữa sprint: Product muốn FAQ rich result chỉ trên VI → thêm
faqoptional vàoblog-vi, không đụngblog. - Tuần 2: Viết stub VI bằng máy dịch +
noIndex: trueđể giữ slug. Rewrite lần lượt, gỡnoIndexkhi đủ độ dài và góc thị trường VN. - Trước merge:
astro buildtrên CI — một PR thêmrelatedToolrequired mà không backfill → fail cả pipeline. Đổi lại.optional(), backfill 5 bài pilot, rồi siết later.
Bài học: schema là contract giữa writer và CI. Thay đổi breaking cần migration plan, không chỉ merge config.ts.
5 lỗi hay gặp (và cách tránh)
tags: "Astro"thay vìtags: ["Astro"]— Zod array fail. Luôn dùng YAML list.- Thêm field required trên schema cũ — hàng trăm MDX thiếu field → build đỏ. Dùng
optional()/default()trước. - Ngày không coerce — sort/list crash. Luôn
z.coerce.date()chopubDate. - Quên
componentskhi render MDX — Callout biến thành lỗi runtime/build. - Trộn locale trong một folder —
getCollectionkhông biết file nào là VI. Tách thư mục hoặc filterlocalechặt.
| Kiểm tra | Pass khi |
|---|---|
| astro build local | Exit 0 trên máy bạn |
| Sample 1 bài mỗi locale | Frontmatter khớp schema mới |
| Field mới | optional hoặc đã backfill |
| noIndex stubs | Không lọt sitemap/list public nếu policy yêu cầu |
| MDX components | Trang detail truyền mdxComponents |
Khi nào Content Collections chưa đủ?
- CMS headless (Contentful, Sanity) với editor non-dev → Collections vẫn hữu ích cho docs nội bộ, không thay CMS.
- Nội dung user-generated runtime → không phải static collection.
- Chỉ 2–3 trang Markdown cố định → có thể overkill; folder
pages/+ import MDX thủ công cũng được.
Còn lại: blog / changelog / learn path đa ngôn ngữ trên Astro — Collections gần như mặc định đúng.
Tóm tắt quyết định
| Nhu cầu | Làm gì |
|---|---|
| Blog 1 ngôn ngữ | Một defineCollection, schema tối thiểu |
| JA + EN + VI | Ba collection, schema VI/EN có translationOf / noIndex |
| Rich result FAQ | faq optional trong schema + JSON-LD ở layout |
| Stub chờ viết lại | noIndex: true đến khi đủ chất lượng |
| Validate object mẫu | JSON Formatter trước khi siết Zod |
Viết Markdown vẫn là công việc chính — Collections chỉ đảm bảo typo frontmatter không lên production.
Liên kết liên quan
- Danh sách công cụ Kawa
- JSON Formatter — kiểm tra object faq/howTo trước khi gắn schema
- Blog VI