blog

1 route, 10+ 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 1 file route. 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 1 file: [[...slug]].js. Trang chủ, trang dịch vụ, danh sách dự án, từng bài blog đều đi qua nó. Không có pages/blog/[slug].js, không có pages/api/.

Chuyện nâng version và những chỗ vấp khi đổi bundler nằm ở bài nâng cấp lên Next.js 16. Ở đây chỉ nói kiến trúc nội dung.

1 catch-all route cho toàn bộ site

getStaticPaths quét toàn bộ content/**/*.md, mỗi file thành 1 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 1 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, đổi lại slug phải unique trên toàn site. Có hai file trùng slug thì getPaths throw ngay lúc build.

Một hệ quả tôi chỉ thấy khi đã lên production: rút gọn URL thì phải 301 đường dẫn cũ, mà quy tắc redirect đầu tiên tôi viết là ^/projects/(.+)$. public/projects/ lại đang phục vụ ảnh tại đúng /projects/*.jpg, cùng namespace với URL trang cũ. Regex không phân biệt được, nên mọi ảnh dự án bị 301 sang /<tên>.jpg rồi 404, và Cloudflare cache luôn cái 301 vĩnh viễn đó.

Siết regex lại thành ([^./]+) thì chữa được, nhưng cách dứt điểm là dời ảnh sang public/screenshots/. Không còn file thật nào nằm dưới /projects/ thì redirect viết kiểu gì cũng không đụng tới. Bài học: trước khi rút gọn URL, kiểm xem tiền tố sắp redirect có đang là thư mục asset thật hay không.

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 1 file

Một trang không chỉ có 1 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.

Người viết nội dung vì vậy 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 code 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: thẻ ảnh luôn có kích thước thật nên không nhảy layout khi ảnh tải xong, và tôi không phải gõ tay width: 1200 cho từng ảnh.

Cơ chế chạy đệ 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.

Ngược lại, icons là computed field tôi dùng sai suốt một thời gian dài. Nó đọc file SVG rồi nhúng thẳng source vào frontmatter. Hợp lý với icon vector thật, vì inline xong mới tô màu được theo currentColor. Nhưng bốn logo khách hàng ở cuối trang chủ lại là bitmap nhúng base64 bên trong SVG. Inline chúng nghĩa là 364kB ảnh nằm trong page data, và Next gửi data hai lần (1 trong HTML, 1 trong /_next/data/*.json) nên con số đó nhân đôi. Bitmap thì vẫn không tô màu theo currentColor được, light mode vẫn phải xử lý bằng filter invert.

Sửa bằng cách tách bitmap ra file rời, resize đúng cỡ hiển thị rồi trả về thẻ <img loading="lazy"> bình thường. Page data trang chủ tụt từ 371kB xuống 13kB, điểm Lighthouse mobile nhảy từ 50 lên 85. Đọc file lúc build rồi nhét vào frontmatter chỉ đáng khi thứ được nhét vào thật sự cần nằm trong luồng render.

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.

Lưu ý: 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, vì 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.
  • Sitemap: next-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.

Đánh đổi khi dồn toàn bộ routing vào một file

Bốn cái giá phải trả:

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

Với một site nội dung ít thay đổi, do chính người viết code vận hành, bốn cái giá đó vẫn rẻ.

Cùng route đó đang render trang dịch vụ và danh sách dự án: khác layout, khác frontmatter, không thêm một file route nào.

Bài viết liên quan

tất cả bài viết →