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.

Next.js 16MDXTailwind CSS
Pipeline dựng site Next.js từ Markdown qua parser, computed fields và layout registry

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.

js
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:

text
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:

js
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:

FieldLà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
tagsChuẩn hoá tag về dạng thống nhất
collectionGlob 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.

yaml
# 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ỉ fetch rồi lọc.
  • RSS — sinh khi slug trang khớp danh sách collection khai trong theme.config.js.
  • Sitemapnext-sitemap chạy ở bước postbuild, ghi thẳng vào out/.

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ụdanh sách dự án — cùng một route, khác layout, khác frontmatter.

Bài viết liên quan