· astro / mdx / frontmatter
Viết frontmatter cho Astro đúng cách (và validate nó)
Thêm Zod schema vào src/content.config.ts. Frontmatter fields của Markdown và MDX có TypeScript type và validate lúc build — không cần dependency ngoài.
Bởi Ethan
1.615 từ · 9 phút đọc
Thêm một Zod schema vào frontmatter Astro. Không có schema, entry.data có type là any và một lỗi đánh máy như titel: sẽ trôi qua im lặng cho đến khi một component throw lỗi lúc runtime. Có schema, cùng lỗi đó là một build error cứng — bắt được trước khi deploy.
Bài này hướng đến Astro 5 (phiên bản stable phổ biến nhất hiện tại). Sự khác biệt với Astro 4 được ghi chú inline. Một callout ở cuối bài nói về những thay đổi trong Astro 6.
Dành cho ai
Người dùng Astro 5 đã có content collections hoạt động và muốn có TypeScript inference cùng validation lúc build cho frontmatter. Bạn cần biết cơ bản về TypeScript — quen với generic types là đủ.
Những gì chúng tôi đã kiểm tra
Astro 5.18.2 (phiên bản 5.x mới nhất tính đến tháng 5/2026), Content Layer API. Các tùy chọn Config API được pin từ tài liệu Astro. Sự khác biệt khi nâng cấp từ Astro 4 lên 5 lấy từ upgrade guide chính thức.
Bước 1: Di chuyển (hoặc tạo) file config
File config của collection đã đổi vị trí trong Astro 5:
src/content.config.ts ← Astro 5+ (đúng)
src/content/config.ts ← Astro 4 (vẫn chạy khi bật cờ legacy.collections)
Nếu bạn đang dùng Astro 5 mà config vẫn ở path cũ, Astro sẽ cảnh báo. Hãy di chuyển nó.
Nếu chưa có file config nào, tạo src/content.config.ts ngay — bước tiếp theo sẽ điền nội dung vào.
Lỗi thường gặp: thiếu file config đồng nghĩa với không có validation và không có TypeScript types. entry.data sẽ là any. Không có gì báo lỗi to — chỉ âm thầm drift.
Bước 2: Định nghĩa schema
// src/content.config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
description: z.string(),
publishDate: z.coerce.date(), // "2024-01-15" → Date object
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
z được re-export từ astro:content — không cần cài riêng zod. Nếu bạn đang cân nhắc giữa các thư viện schema validation, xem Zod vs Valibot.
z.coerce.date() chuyển đổi date string (kiểu YAML 2024-01-15) thành Date object lúc build. Component của bạn nhận được một Date thực sự, không phải một string phải tự parse thủ công.
.default([]) và .default(false) cho phép tác giả bỏ qua các field này — nếu key vắng mặt, Astro dùng giá trị mặc định. Bỏ qua một field không có default là vi phạm schema.
Lỗi thường gặp: nếu thiếu field schema trong defineCollection, bạn không có validation gì cả. Collection vẫn hoạt động, nhưng entry.data vẫn là any.
Bước 3: Dùng type đã suy luận trong component
Khi schema đã có, CollectionEntry<'blog'> cho bạn một TypeScript type chính xác ở bất kỳ đâu bạn truyền một post:
import type { CollectionEntry } from 'astro:content';
interface Props {
post: CollectionEntry<'blog'>;
}
const { post } = Astro.props;
// post.data.title → string ✓
// post.data.publishDate → Date ✓
// post.data.tags → string[] ✓
Không cần cast, không cần as any. Type được suy luận trực tiếp từ schema.
Pattern dynamic route đầy đủ:
// src/pages/blog/[...id].astro
---
import { getCollection, render } from 'astro:content';
import type { CollectionEntry } from 'astro:content';
export async function getStaticPaths() {
const posts = (await getCollection('blog', ({ data }) => !data.draft))
.sort((a, b) => b.data.publishDate.getTime() - a.data.publishDate.getTime());
return posts.map(post => ({
params: { id: post.id }, // ← 'id', không phải 'slug' — thay đổi từ Astro 5
props: { post },
}));
}
interface Props { post: CollectionEntry<'blog'> }
const { post } = Astro.props;
const { Content } = await render(post); // ← standalone render() — thay đổi từ Astro 5
---
<article>
<h1>{post.data.title}</h1>
<Content />
</article>
Người dùng Astro 4: có hai thay đổi. entry.slug đổi thành entry.id. entry.render() đổi thành standalone render(entry) import từ astro:content. Đây là hai breaking change ảnh hưởng đến hầu hết template.
Lỗi thường gặp: getCollection() trả về entries theo thứ tự không xác định trong Astro 5. Nếu bỏ qua .sort(), thứ tự bài viết sẽ khác nhau giữa local dev và CI. Luôn sort tường minh.
Bước 4: Thêm validation vào CI
astro build tự động validate tất cả collection entries. Một vi phạm schema sẽ dừng build:
blog → posts/my-post.md frontmatter does not match collection schema.
'title' is required.
'publishDate' must be a valid date.
Để bắt lỗi trước khi build, chạy:
npx astro check
astro check thực hiện TypeScript type-checking và validation content collection mà không cần compile toàn bộ site. Chỉ mất vài giây và bắt được lỗi đánh máy trước khi push.
Thêm vào CI:
# .github/workflows/ci.yml
- name: Check types and content
run: npx astro check
Lỗi thường gặp: không có bước này trong CI, một tác giả nội dung có thể push frontmatter sai lên branch mà không ai biết cho đến khi ai đó chạy astro build. Trong monorepo mà build chỉ chạy khi deploy, feedback loop đó có thể rất dài.
Những điểm cần chú ý
image() yêu cầu wrapper dạng function-schema
Nếu bạn muốn pipeline tối ưu hóa ảnh của Astro xử lý một field cover, dùng image() thay vì z.string():
// Sai — lưu một string, bỏ qua tối ưu hóa ảnh
cover: z.string()
// Đúng — chuyển thành ImageMetadata, dùng được với <Image />
cover: image()
image() yêu cầu option schema phải là một function, không phải plain object:
schema: ({ image }) =>
z.object({
// ...
cover: image().optional(),
}),
Dùng image() bên trong một z.object({...}) thông thường sẽ throw build error. Ảnh cũng phải nằm trong src/ — ảnh trong public/ bỏ qua asset pipeline.
Một giới hạn hiện tại: image().refine() không được hỗ trợ trong Content Layer API của Astro 5. Validation tùy chỉnh cho image fields phải thực hiện ở runtime.
.optional() vs. .nullable() vs. .default()
Ba cái này không giống nhau:
// .optional() — field có thể vắng mặt hoàn toàn (type: Date | undefined)
updatedDate: z.coerce.date().optional()
// .nullable() — field phải có mặt nhưng có thể là null (type: string | null)
hero: z.string().nullable()
// .default() — field vắng mặt sẽ nhận giá trị mặc định; undefined không bao giờ xuất hiện trong output
draft: z.boolean().default(false)
Trong frontmatter, hầu hết các field “tùy chọn” nên dùng .optional() hoặc .default() — tác giả nội dung bỏ qua field đó hoàn toàn. Chỉ dùng .nullable() khi CMS hoặc tooling của bạn ghi tường minh null cho các field vắng mặt.
Tham chiếu giữa các collection
reference() validate rằng một field trong frontmatter trỏ đến một entry tồn tại trong collection khác:
import { defineCollection, z, reference } from 'astro:content';
const blog = defineCollection({
// ...
schema: z.object({
author: reference('authors'), // được validate lúc build
}),
});
Một tham chiếu đến author ID không tồn tại sẽ fail lúc build. Đây là hành vi đúng — thà báo lỗi lúc build còn hơn thiếu trang author trên production.
Field layout đã bị xóa
Astro 5 đã xóa field frontmatter đặc biệt layout vốn tự động bọc content collection entries trong một layout component. Nếu bạn đang migrate từ Astro 4 và frontmatter có layout: '../layouts/BlogPost.astro', nó sẽ nằm trong entry.data.layout như một plain string và không làm gì cả. Hãy import layout tường minh trong file dynamic route.
Những gì frontmatter tốt mở ra
Khi frontmatter đã được typed theo schema và validate lúc build, các integration downstream hoạt động mà không cần glue code thêm:
| Tính năng | Fields cần thiết |
|---|---|
| RSS feed | title, description, publishDate, author |
| Sitemap | publishDate, updatedDate (lastmod), draft (cờ loại trừ) |
| OG images | title, description, cover |
| Related posts | tags (overlap), translationKey (i18n sibling) |
| Author pages | author qua reference('authors') |
Có updatedDate là một Date object trong schema cho các integration một giá trị sạch để làm việc. Lưu ý: @astrojs/sitemap không thể đọc trực tiếp source code của trang — một giới hạn của Astro Integration API — nên muốn set lastmod trong sitemap cần một callback serialize() tùy chỉnh trong cấu hình sitemap. Tương tự, @astrojs/rss yêu cầu các field frontmatter phải được map tường minh trong route handler của feed thay vì đọc tự động.
Astro 6 (tháng 3/2026): Cờ
legacy.collectionsđã bị xóa. Content Layer API là con đường duy nhất được hỗ trợ. Nếu bạn đang nâng cấp, xem Astro v6 upgrade guide và đánh giá Astro 6 của chúng tôi. Lưu ý cho tác giả custom loader: propertyschemacallback trong interfaceLoaderđược thay thế bằngcreateSchema()— điều này chỉ ảnh hưởng đến implementation của loader, không phảidefineCollectionconfig thông thường.
Lưu ý bổ sung
- MDX frontmatter không phải một phần của MDX spec. Đây là tính năng riêng của Astro, thêm qua
remark-frontmatter. Compile thô với@mdx-js/mdxsẽ không parse block---nếu không inject cùng plugin thủ công. image().refine()hiện không được hỗ trợ trong Content Layer API — giới hạn đã biết tính đến Astro 5.18.2.