Một route, mười layout — kiến trúc site Next.js chạy hoàn toàn bằng Markdown
Toàn bộ site này render từ đúng một file route. Ghi chép về pipeline phía sau — frontmatter chia section, computed fields, registry layout và collection.
Thư mục pages/ của site này có đúng một file: [[...slug]].js. Trang chủ, trang dịch vụ, danh sách dự án, từng bài blog — tất cả đi qua nó. Không có pages/blog/[slug].js, không có pages/api/.
Bài này mổ xẻ cách nó hoạt động. Nếu bạn quan tâm chuyện nâng version và những chỗ vấp khi đổi bundler, phần đó nằm ở bài nâng cấp lên Next.js 16 — ở đây tôi chỉ nói về kiến trúc nội dung.
Một catch-all route cho toàn bộ site
getStaticPaths quét toàn bộ content/**/*.md, mỗi file thành một path. getStaticProps nhận slug, tìm đúng file Markdown tương ứng rồi parse.
export async function getStaticPaths() {
const pages = await getPaths()
const paths = pages.map((s) => ({ params: { slug: s.slug } }))
return { paths, fallback: false }
}
Hệ quả thực tế: thêm một trang mới không cần đụng vào code — tạo file .md là xong.
Thư mục nào khai trong mdxConfig.flattenDirs thì bị lược khỏi URL: content/blog/abc.md ra thẳng /abc, không phải /blog/abc. URL ngắn hơn, nhưng đổi lại slug phải unique trên toàn site — getPaths throw ngay lúc build nếu có hai file trùng slug.
Cái giá phải trả là mọi routing logic dồn vào một chỗ. Đổi lại, tôi không bao giờ phải tự hỏi "trang này render từ file nào".
Frontmatter chia section — YAML và MDX trong cùng một file
Đây là phần tôi thích nhất. Một trang không chỉ có một khối frontmatter, mà nhiều section — mỗi section mở đầu bằng ba dấu gạch kèm tên (---main, ---achievements), có YAML riêng và phần MDX riêng.
Trang chủ của site này trông như sau:
content/index.md
├─ frontmatter layout: Home
├─ section main eyebrow + H1 + hai nút CTA
├─ section achievements 4 ô số liệu
├─ section projects collection /projects, limit 4
├─ section blog collection /blog, limit 3
├─ section cta tiêu đề + nút liên hệ
└─ section companies danh sách logo
Parser dùng option section của gray-matter, js-yaml load phần data, còn phần nội dung đi qua next-mdx-remote/serialize:
const { data, content, sections } = await matter.read(filePath, {
section: (section) => {
if (typeof section.data === 'string' && section.data.trim() !== '') {
section.data = yaml.load(section.data)
}
section.content = section.content.trim()
},
})
Mỗi section trở thành một prop truyền thẳng vào layout. ---main thành props.main, ---achievements thành props.achievements. Có cả cú pháp mảng: ---services[0], ---services[1] gom lại thành props.services dạng array.
Nghĩa là người viết nội dung sắp xếp được cả cấu trúc trang, không chỉ chữ — mà vẫn không rời khỏi file Markdown.
Một cái bẫy tôi dính ngay khi viết chính bài này: section-matter quét theo từng dòng và trim() trước khi so khớp, nó không biết code fence là gì. Một dòng bắt đầu bằng ---main nằm trong khối ``` vẫn bị tính là dấu mở section thật, và toàn bộ phần sau đó biến khỏi bài viết — build vẫn xanh, không một cảnh báo nào. Đó là lý do sơ đồ ở trên vẽ bằng cây thư mục thay vì in thẳng cú pháp. Dấu --- đứng một mình (đường kẻ ngang trong Markdown) thì vẫn an toàn.
Computed fields: frontmatter tự bổ sung dữ liệu lúc build
Frontmatter viết tay thường thiếu thứ mà UI cần. Bốn computed field chạy lúc build để bù vào:
| Field | Làm gì |
|---|---|
images | Đọc file ảnh bằng image-size, gắn width/height thật vào frontmatter |
icons | Đọc file SVG từ đĩa, nhúng thẳng inline vào markup |
tags | Chuẩn hoá tag về dạng thống nhất |
collection | Glob các file con, lọc, sắp xếp, phân trang |
Field images đáng nói nhất: nhờ nó mà thẻ ảnh luôn có kích thước thật, không bị nhảy layout khi ảnh tải xong. Tôi không phải gõ tay width: 1200 cho từng ảnh.
Cơ chế đệ quy — parser duyệt toàn bộ cây frontmatter, gặp key trùng tên computed field thì gọi resolve(), xong lại duyệt tiếp vào kết quả. Nhờ vậy collection lồng trong một section vẫn được resolve bình thường.
Layout registry: layout: trong frontmatter chọn component
Frontmatter khai layout: BlogPost, registry layouts/index.js trả về đúng component qua dynamic import. Hiện có mười layout: Home, About, Services, Contact, ProjectList, ProjectDetail, BlogList, BlogPost, Fallback, BlankCentered.
Một lưu ý đã cắn tôi một lần: layout không có trong registry thì trang render ra null — trắng trơn, không lỗi, không cảnh báo. Thêm layout mới nhớ đăng ký.
Collection và phân trang
collection nhận path, sortBy, filterBy, limit, recordsPerPage. Phần lọc dùng sift nên viết được cú pháp kiểu MongoDB.
# phần YAML bên trong section `projects` của trang chủ
title: Dự án nổi bật
collection:
path: /projects
sortBy: date
limit: 4
Trang chủ dùng nó để lấy 4 dự án mới nhất; trang /du-an dùng cùng cơ chế nhưng không limit và có phân trang.
Một chi tiết dễ nhầm: phân trang chỉ chạy khi collection nằm ở frontmatter cấp cao nhất của trang list. Đặt trong section thì vẫn resolve ra records, nhưng không sinh route /x/page/2. Records trả về client cũng được gọt bớt trường — card list không cần cả body MDX.
Đẩy hết về build time
Không có server, nên mọi thứ động phải giải quyết lúc build hoặc ở phía client:
- Search index cho ⌘K — một script Node quét frontmatter, ghi ra JSON kèm nhóm hiển thị. Client chỉ
fetchrồi lọc. - RSS — sinh khi slug trang khớp danh sách collection khai trong
theme.config.js. - Sitemap —
next-sitemapchạy ở bướcpostbuild, ghi thẳng vàoout/.
Kết quả cuối cùng là một thư mục HTML tĩnh. Nginx trỏ thẳng vào đó, không process nào chạy nền.
Phần đánh đổi
Kiến trúc này không miễn phí, và tôi nghĩ nên nói thẳng:
Build time tăng theo số trang. Mỗi trang là một lần đọc file, parse YAML, serialize MDX. Vài chục trang thì không cảm nhận được. Vài nghìn thì phải tính lại.
Không có preview nội dung. Sửa Markdown xong phải build mới thấy bản thật. Với CMS có preview realtime thì đây là bước lùi.
Lỗi frontmatter chỉ lộ lúc build. Một dấu hai chấm không đặt trong ngoặc là gãy build. Không có schema validation, không có gợi ý trong editor.
Nội dung buộc phải nằm trong repo. Người không dùng Git thì không sửa được bài. Với site cá nhân thì hợp lý; với khách hàng cần tự đăng tin thì tôi vẫn dựng CMS bình thường.
Chọn kiến trúc là chọn tập đánh đổi, không phải chọn cái "tốt nhất". Với một site nội dung ít thay đổi, do chính người viết code vận hành, đánh đổi ở trên là hời.
Cùng pipeline này đang chạy trang dịch vụ và danh sách dự án — cùng một route, khác layout, khác frontmatter.
Bài viết liên quan