Astro Content Collections cho blog đa ngôn ngữ | Schema MDX + Zod 2026

(Cập nhật: 19 tháng 7, 2026 ) Astro Content Collections MDX Zod blog đa ngôn ngữ i18n frontmatter
Kết luận

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 để:

  1. Đặt file Markdown/MDX trong src/content/<tên-collection>/
  2. Khai báo Zod schema trong src/content/config.ts
  3. 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ụcCollectionRoute
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

Kiến trúc i18n với Content Collections
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.

translationOfnoIndex

  • 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ỡ.

Validate JSON mẫu trước khi nhét schema

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.mdxhello-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.

  1. Tuần 1: Schema tối thiểu chung (title, description, pubDate, tags). Ship ~30 bài JA.
  2. Giữa sprint: Product muốn FAQ rich result chỉ trên VI → thêm faq optional vào blog-vi, không đụng blog.
  3. Tuần 2: Viết stub VI bằng máy dịch + noIndex: true để giữ slug. Rewrite lần lượt, gỡ noIndex khi đủ độ dài và góc thị trường VN.
  4. Trước merge: astro build trên CI — một PR thêm relatedTool required 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)

  1. tags: "Astro" thay vì tags: ["Astro"] — Zod array fail. Luôn dùng YAML list.
  2. Thêm field required trên schema cũ — hàng trăm MDX thiếu field → build đỏ. Dùng optional() / default() trước.
  3. Ngày không coerce — sort/list crash. Luôn z.coerce.date() cho pubDate.
  4. Quên components khi render MDX — Callout biến thành lỗi runtime/build.
  5. Trộn locale trong một foldergetCollection không biết file nào là VI. Tách thư mục hoặc filter locale chặt.
Checklist trước khi merge schema
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ầuLàm gì
Blog 1 ngôn ngữMột defineCollection, schema tối thiểu
JA + EN + VIBa collection, schema VI/EN có translationOf / noIndex
Rich result FAQfaq optional trong schema + JSON-LD ở layout
Stub chờ viết lạinoIndex: true đến khi đủ chất lượng
Validate object mẫuJSON 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