Hướng Dẫn MDX: Kết Hợp Markdown và JSX Cho Lập Trình Viên

Nếu bạn từng ước Markdown có thể render một trình soạn thảo code trực tiếp, một biểu đồ tương tác, hoặc một component callout tùy chỉnh — thì đó chính xác là điều MDX sinh ra để làm.
MDX là một định dạng file kết hợp sự tiện lợi khi viết của Markdown với toàn bộ sức mạnh của JSX component. Đây là nền tảng của các trang tài liệu như Nextra, Docusaurus, và vô số blog cá nhân của các lập trình viên — bao gồm cả blog này.
Sau khi hoàn thành hướng dẫn này, bạn sẽ có thể:
✅ MDX là gì và nó khác Markdown như thế nào
✅ Cú pháp MDX: JSX trong Markdown và Markdown trong JSX
✅ Thiết lập MDX với Next.js App Router
✅ Pipeline plugin remark và rehype
✅ Xây dựng custom component (callout, code tab, diagram)
✅ Xử lý frontmatter và metadata
✅ MDX vs các giải pháp thay thế (MDsveX, Markdoc, Contentlayer)
✅ Các pattern thực tế và best practice
MDX Là Gì?
MDX là một định dạng file cho phép bạn viết JSX bên trong các tài liệu Markdown. Hãy nghĩ về nó như Markdown với siêu năng lực: bạn có được tất cả sự tiện lợi của Markdown (tiêu đề, danh sách, code block) cộng với khả năng tái sử dụng của React component.
Một file MDX tối giản trông như thế này:
# Hello World
Đây là **Markdown** bình thường.
<MyComponent greeting="Xin chào từ JSX!" />
Và chúng ta quay lại với _Markdown_ bên dưới component.Phần mở rộng .mdx báo hiệu cho toolchain biết rằng file này cần được xử lý như MDX thay vì Markdown thuần. Bên dưới, MDX được biên dịch thành JavaScript — mỗi file MDX trở thành một React component mà bạn có thể import và render như bất kỳ component nào khác.
Vấn Đề MDX Giải Quyết
Markdown tĩnh rất xuất sắc cho nội dung chứa nhiều văn bản. Nó dễ đọc ở dạng source, di động, và được hiểu phổ biến. Nhưng nó gặp giới hạn rõ ràng ngay khi bạn muốn bất cứ thứ gì tương tác.
Bạn muốn một code block có tab để người đọc có thể chuyển đổi giữa ví dụ JavaScript và TypeScript? Không thể làm điều đó với Markdown thuần. Cần một callout cảnh báo được tạo kiểu với icon? Bạn phải viết HTML thuần — vừa xấu trong source vừa không thể sử dụng design system của bạn. Muốn nhúng một biểu đồ trực tiếp lấy dữ liệu từ API? Không thể.
JSX thuần giải quyết tất cả điều đó, nhưng nó rườm rà cho nội dung chứa nhiều văn bản. Viết toàn bộ bài blog bằng JSX có nghĩa là bọc mỗi đoạn trong thẻ <p> và liên tục escape các ký tự đặc biệt.
MDX là điểm cân bằng hoàn hảo: văn xuôi trong Markdown, tính tương tác trong JSX. Bạn viết đoạn văn và tiêu đề một cách tự nhiên. Khi bạn cần một custom component, bạn đặt nó vào. Tất cả còn lại vẫn gọn gàng.
MDX vs Markdown Thuần
| Tính năng | Markdown | MDX |
|---|---|---|
| Văn xuôi / tiêu đề / danh sách | ✅ | ✅ |
| Code block (tĩnh) | ✅ | ✅ |
| Inline HTML | ✅ | ✅ (giới hạn) |
| React component | ❌ | ✅ |
| Biểu thức JavaScript | ❌ | ✅ |
| Import/export | ❌ | ✅ |
| Nội dung động | ❌ | ✅ |
Ghi chú "giới hạn" về inline HTML rất quan trọng: MDX có hỗ trợ một số inline HTML, nhưng HTML đầy đủ không phải lúc nào cũng được pass qua an toàn tùy thuộc vào cấu hình rehype. Để có tính tương tác phong phú, hãy luôn ưu tiên JSX component hơn là HTML thuần trong MDX.
Cú Pháp MDX Chi Tiết
Import và Sử Dụng Component
Một trong những tính năng cốt lõi của MDX là khả năng import React component ở đầu file, giống như trong file .tsx hay .jsx. Những component đó sau đó có thể dùng ở bất kỳ đâu trong phần thân tài liệu.
import { Callout } from '../components/Callout'
import { Chart } from '../components/Chart'
# Bài viết của tôi
<Callout type="warning">
Đây là một **cảnh báo** bên trong React component, bên trong MDX.
</Callout>
Đây là dữ liệu theo quý:
<Chart data={[10, 25, 40, 35]} />Lưu ý rằng Markdown vẫn hoạt động bình thường giữa và xung quanh các component. MDX compiler biết cách xử lý nội dung giữa các dòng trống là văn xuôi và các khối JSX là component.
Biểu Thức JavaScript
Bạn có thể nhúng các biểu thức JavaScript trực tiếp trong MDX bằng cách dùng dấu ngoặc nhọn {}. Điều này hoạt động với bất kỳ biểu thức JavaScript hợp lệ nào — không phải câu lệnh, mà là biểu thức phân giải thành một giá trị.
export const year = new Date().getFullYear()
Hướng dẫn này được viết vào năm {year}.
{/* Đây là comment — nó sẽ không xuất hiện trong output */}
Kết quả là {40 + 2}.Các câu lệnh export ở cấp độ cao nhất trong file MDX định nghĩa các biến có thể dùng trong phần thân tài liệu và cũng được export cho trang consuming đọc — điều này hữu ích cho metadata.
Export Metadata (Thay Thế Cho Frontmatter)
Trước khi YAML frontmatter trở thành tiêu chuẩn trong MDX tooling, cộng đồng đã dùng export const để gắn metadata vào file MDX:
export const meta = {
title: 'Bài viết của tôi',
date: '2026-02-25',
tags: ['mdx', 'react'],
}
# Bài viết của tôi
Nội dung ở đây...Ngày nay, hầu hết tooling (gray-matter, next-mdx-remote với parseFrontmatter: true) đều hỗ trợ YAML frontmatter trực tiếp. Nhưng bạn có thể gặp pattern export const meta trong các codebase cũ hơn.
Kết Hợp Văn Xuôi và Component
Các quy tắc chính để kết hợp văn xuôi Markdown và JSX component trong MDX:
- Các dòng trống phân tách đoạn văn Markdown và khối JSX
- Các khối JSX phải bắt đầu từ đầu dòng (không có khoảng trắng đầu)
- Thẻ JSX tự đóng hoạt động:
<MyComponent /> - Children hoạt động bình thường:
<Box>nội dung ở đây</Box> - Markdown bên trong JSX children được xử lý đệ quy
Đây là một ví dụ phức tạp hơn cho thấy cấu trúc lồng nhau. Trong file .mdx của bạn, một component tabs trông như thế này:
Đây là văn bản trước component tabs.
[Tabs]
[Tab label="JavaScript"]
(khối code javascript ở đây)
[/Tab]
[Tab label="TypeScript"]
(khối code typescript ở đây)
[/Tab]
[/Tabs]
Quay lại văn xuôi bình thường sau component.(Dấu ngoặc nhọn được thể hiện dưới dạng dấu ngoặc vuông ở trên để tránh nhầm lẫn cho MDX parser trong chính bài viết này — cú pháp thực tế dùng <Tabs>, <Tab>, và </Tabs>.)
Pattern này — bọc code block trong component <Tabs> tùy chỉnh — là một trong những cách dùng MDX phổ biến nhất trong tài liệu kỹ thuật.
Thiết Lập MDX với Next.js App Router
Có ba cách tiếp cận chính để dùng MDX trong dự án Next.js. Lựa chọn đúng phụ thuộc vào nơi nội dung của bạn tồn tại và mức độ linh hoạt bạn cần.
Phương Pháp 1: @next/mdx (Chính Thức, Đơn Giản)
Tích hợp MDX chính thức của Next.js xử lý các file .mdx trong thư mục app/ như các trang. Đây là thiết lập đơn giản nhất và đúng đắn nếu nội dung và code của bạn sống cùng nhau.
npm install @next/mdx @mdx-js/loader @mdx-js/react
npm install -D @types/mdxCấu hình next.config.mjs:
import createMDX from '@next/mdx'
const withMDX = createMDX({
options: {
remarkPlugins: [],
rehypePlugins: [],
},
})
export default withMDX({
// Báo cho Next.js xử lý file .mdx như trang
pageExtensions: ['js', 'jsx', 'mdx', 'ts', 'tsx'],
})Bây giờ tạo app/blog/my-post/page.mdx và nó tự động trở thành route /blog/my-post. Bạn cũng có thể tạo mdx-components.tsx ở root của dự án để cung cấp custom component toàn cục.
Phương Pháp 2: next-mdx-remote (Cho Nội Dung Ngoài app/)
Đây là pattern được dùng trong blog này — nội dung nằm trong content/posts/ hoàn toàn tách biệt khỏi app router của Next.js. Một trang route động fetch và biên dịch MDX tại thời điểm request (hoặc build time với static generation).
npm install next-mdx-remote// app/[locale]/blog/[slug]/page.tsx
import { MDXRemote } from 'next-mdx-remote/rsc'
import { getPostBySlug } from '@/lib/posts'
export default async function PostPage({
params,
}: {
params: { slug: string; locale: string }
}) {
const { content, frontmatter } = await getPostBySlug(params.slug, params.locale)
return (
<article>
<h1>{frontmatter.title}</h1>
<MDXRemote source={content} components={mdxComponents} />
</article>
)
}// lib/mdx-components.tsx — custom component có sẵn trong tất cả file MDX
const mdxComponents = {
Callout,
CodeTabs,
// Ghi đè các phần tử HTML mặc định bằng implementation tùy chỉnh:
pre: CustomCodeBlock,
a: CustomLink,
img: CustomImage,
}Hậu tố /rsc trong import rất quan trọng — nó dùng React Server Component, nghĩa là việc biên dịch MDX xảy ra trên server mà không có chi phí JavaScript phía client nào cho bản thân quá trình biên dịch.
Phương Pháp 3: Vite + MDX Plugin
Với các dự án ngoài Next.js — Remix, React thuần + Vite, hay các SPA framework — plugin @mdx-js/rollup tích hợp MDX vào pipeline build của Vite.
npm install -D @mdx-js/rollup// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import mdx from '@mdx-js/rollup'
export default defineConfig({
plugins: [
// mdx() phải đứng trước react()
mdx(),
react(),
],
})Sau đó, bạn có thể import trực tiếp các file .mdx như React component:
import MyPost from './posts/my-post.mdx'
export default function App() {
return <MyPost />
}Pipeline Plugin: Remark và Rehype
Quá trình xử lý MDX không phải là một bước chuyển đổi đơn lẻ. Nó đưa nội dung của bạn qua một pipeline hai giai đoạn AST (Abstract Syntax Tree). Hiểu pipeline này rất cần thiết để cấu hình MDX đúng cách và viết plugin của riêng bạn.
Giai đoạn 1 — remark (Markdown AST): Văn bản MDX nguồn được parse thành Markdown AST gọi là mdast. Các remark plugin hoạt động trên cây này. Chúng có thể thêm, xóa, hoặc chuyển đổi các node trước khi cây được chuyển đổi sang HTML.
Giai đoạn 2 — rehype (HTML AST): mdast được chuyển đổi thành HTML AST gọi là hast. Các rehype plugin hoạt động ở đây. Chúng làm việc với các node ở cấp độ HTML — hữu ích cho syntax highlighting, heading ID, và link transformation.
Remark Plugin (Giai Đoạn Markdown)
Các plugin này xử lý nội dung của bạn ở cấp độ Markdown — trước khi nó trở thành HTML:
- remark-gfm: GitHub Flavored Markdown — bảng, gạch ngang, task list, autolink, và footnote. Hầu hết mọi dự án MDX đều dùng plugin này.
- remark-math: Biểu thức toán học kiểu LaTeX với cú pháp
$inline$và$$block$$. - remark-toc: Tự động tạo mục lục từ các tiêu đề.
- remark-frontmatter: Parse YAML hoặc TOML frontmatter để AST nhận biết được nó (kết hợp với remark-mdx-frontmatter để trích xuất giá trị).
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
const options = {
remarkPlugins: [remarkGfm, remarkMath],
}Rehype Plugin (Giai Đoạn HTML)
Các plugin này xử lý HTML AST — sau khi Markdown đã được chuyển đổi sang HTML:
- rehype-pretty-code: Syntax highlighting đẹp được hỗ trợ bởi Shiki. Hỗ trợ 100+ ngôn ngữ và hàng chục theme bao gồm
github-dark,dracula, vànord. - rehype-slug: Tự động tạo thuộc tính
idcho tất cả tiêu đề dựa trên nội dung văn bản của chúng, cho phép anchor link. - rehype-autolink-headings: Thêm anchor link
<a>vào mỗi tiêu đề (hoạt động với rehype-slug). - rehype-katex: Render biểu thức toán học từ remark-math thành KaTeX HTML sẵn sàng hiển thị.
- rehype-sanitize: Loại bỏ các thuộc tính và thẻ HTML nguy hiểm — cần thiết nếu bạn cho phép bất kỳ nội dung nào từ người dùng.
import rehypeSlug from 'rehype-slug'
import rehypePrettyCode from 'rehype-pretty-code'
const options = {
rehypePlugins: [
// Thêm ID vào tiêu đề trước
rehypeSlug,
// Sau đó áp dụng syntax highlighting
[rehypePrettyCode, { theme: 'github-dark' }],
],
}Thứ tự plugin quan trọng. Một số plugin phụ thuộc vào output của plugin khác — rehype-autolink-headings, chẳng hạn, cần rehype-slug chạy trước để heading ID đã tồn tại khi nó đi thêm link.
Xây Dựng Custom Component
Custom component là nơi MDX thực sự tỏa sáng. Đây là những khối xây dựng chuyển đổi một blog bình thường thành trải nghiệm tài liệu tương tác.
Component Callout
Callout là một hộp được tạo kiểu dùng để làm nổi bật thông tin quan trọng — cảnh báo, mẹo, ghi chú, hoặc lỗi. Đây là một implementation TypeScript đầy đủ kiểu:
// components/Callout.tsx
type CalloutType = 'info' | 'warning' | 'error' | 'tip'
const styles: Record<CalloutType, string> = {
info: 'bg-blue-50 border-blue-400 text-blue-800',
warning: 'bg-yellow-50 border-yellow-400 text-yellow-800',
error: 'bg-red-50 border-red-400 text-red-800',
tip: 'bg-green-50 border-green-400 text-green-800',
}
const icons: Record<CalloutType, string> = {
info: 'ℹ️',
warning: '⚠️',
error: '❌',
tip: '💡',
}
export function Callout({
type = 'info',
children,
}: {
type?: CalloutType
children: React.ReactNode
}) {
return (
<div className={`border-l-4 p-4 my-4 rounded-r ${styles[type]}`}>
<span className="mr-2">{icons[type]}</span>
{children}
</div>
)
}Cách dùng trong file MDX:
<Callout type="warning">
Luôn validate input của người dùng trước khi truyền vào MDX rendering.
</Callout>
<Callout type="tip">
Dùng `short_description` trong frontmatter để có văn bản OG image gọn hơn.
</Callout>Code Block Có Tab
Đây là component người đọc thấy nhiều nhất trên các trang tài liệu — các tab cho phép bạn chuyển đổi giữa các ví dụ ngôn ngữ mà không cần cuộn:
// components/CodeTabs.tsx
'use client'
import { useState } from 'react'
type Tab = {
label: string
children: React.ReactNode
}
export function CodeTabs({ tabs }: { tabs: Tab[] }) {
const [active, setActive] = useState(0)
return (
<div className="rounded-lg border overflow-hidden my-4">
<div className="flex border-b bg-gray-50 dark:bg-gray-800">
{tabs.map((tab, i) => (
<button
key={tab.label}
onClick={() => setActive(i)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
active === i
? 'border-b-2 border-blue-500 text-blue-600'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400'
}`}
>
{tab.label}
</button>
))}
</div>
<div>{tabs[active].children}</div>
</div>
)
}Lưu ý directive 'use client' — bất kỳ component nào dùng useState, useEffect, hoặc các React hook khác phải là Client Component trong Next.js App Router. Bản thân file MDX vẫn được render phía server; chỉ logic chuyển đổi tab tương tác mới chạy phía client.
Ghi Đè Phần Tử Mặc Định
Một trong những tính năng mạnh mẽ nhất của MDX là khả năng thay thế các phần tử HTML mặc định bằng React component của riêng bạn trên toàn cục — mà không cần thay đổi bất kỳ file MDX nguồn nào.
// mdx-components.tsx (quy ước Next.js — đặt ở root dự án)
import type { MDXComponents } from 'mdx/types'
import Image from 'next/image'
import Link from 'next/link'
export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
// Thay thế <img> bằng Next.js Image để tối ưu hóa tự động
img: ({ src, alt, ...props }) => (
<Image
src={src!}
alt={alt ?? ''}
width={800}
height={400}
className="rounded-lg"
{...props}
/>
),
// Thay thế <a> bằng Next.js Link để điều hướng phía client
a: ({ href, children, ...props }) => (
<Link href={href ?? '#'} {...props}>
{children}
</Link>
),
// Thay thế <pre> bằng code block tùy chỉnh có nút copy
pre: CustomCodeBlock,
// Merge với các component override theo từng file
...components,
}
}Khi MDX gặp  trong Markdown của bạn, nó render thành <img>. Với override này, nó render thành <Image> từ next/image thay vào đó — tự động có lazy loading, tối ưu kích thước, và blur placeholder. Tất cả mà không cần chạm vào file nội dung MDX của bạn.
Xử Lý Frontmatter
Bản thân MDX không tự parse YAML frontmatter. Khối --- ở đầu file MDX chỉ là văn bản trừ khi bạn cấu hình một plugin hoặc thư viện để xử lý nó.
Với gray-matter (Phổ Biến Nhất)
gray-matter là tiêu chuẩn thực tế để parse frontmatter trong Node.js. Nó tách khối YAML và trả về dưới dạng object JavaScript:
// lib/posts.ts
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
export async function getPostBySlug(slug: string, locale: string) {
const filePath = path.join(
process.cwd(),
'content/posts',
locale,
`${slug}.mdx`
)
const rawContent = fs.readFileSync(filePath, 'utf-8')
// matter() tách frontmatter và trả về riêng biệt
const { data: frontmatter, content } = matter(rawContent)
return { frontmatter, content }
}content trả về từ matter() là phần thân MDX không có khối frontmatter — sẵn sàng được truyền vào next-mdx-remote hoặc biên dịch trực tiếp.
Với next-mdx-remote và parseFrontmatter
next-mdx-remote v5+ có thể parse frontmatter trong khi biên dịch, với TypeScript generic để đảm bảo type safety hoàn toàn:
import { compileMDX } from 'next-mdx-remote/rsc'
// Định nghĩa hình dạng của frontmatter
type PostFrontmatter = {
title: string
description: string
date: string
tags: string[]
}
const { content, frontmatter } = await compileMDX<PostFrontmatter>({
source: rawContent,
options: { parseFrontmatter: true },
components: mdxComponents,
})
// frontmatter bây giờ được typed là PostFrontmatter
console.log(frontmatter.title) // string
console.log(frontmatter.tags) // string[]MDX vs Các Giải Pháp Thay Thế
Trước khi cam kết với MDX, đáng để hiểu các giải pháp thay thế và những đánh đổi mà mỗi cái tạo ra.
| Tính năng | MDX | MDsveX | Markdoc | Contentlayer |
|---|---|---|---|---|
| Framework | React/bất kỳ | Svelte | Bất kỳ (qua tag) | Bất kỳ |
| JSX trong nội dung | ✅ JSX đầy đủ | ✅ Svelte component | ⚠️ Chỉ custom tag | ❌ |
| Biểu thức JS | ✅ | ✅ | ❌ | ❌ |
| Type safety | ⚠️ Thủ công | ⚠️ Thủ công | ✅ Dựa trên schema | ✅ Dựa trên schema |
| Bảo mật | ⚠️ Rủi ro với nội dung người dùng | ⚠️ Rủi ro | ✅ An toàn theo thiết kế | ✅ |
| Độ khó học | Trung bình | Trung bình | Thấp | Thấp |
| Hệ sinh thái | Lớn | Nhỏ | Đang phát triển | Đã lưu trữ |
MDsveX là tương đương trực tiếp của MDX trong hệ sinh thái Svelte. Nếu bạn đang xây dựng với SvelteKit, MDsveX cho bạn trải nghiệm Markdown + component tương tự.
Markdoc (của Stripe) có triết lý khác: nó dùng custom tag thay vì JSX tùy ý, làm cho nó an toàn và dự đoán được hơn — nhưng cũng hạn chế hơn. Bạn không thể viết biểu thức JavaScript tùy ý trong nội dung Markdoc.
Contentlayer là một content SDK type-safe phổ biến tự động tạo TypeScript type từ schema frontmatter MDX của bạn. Nó đã được lưu trữ và không còn được duy trì tích cực từ năm 2024.
Khi Nào Nên Dùng MDX
- Các trang tài liệu cho thư viện component hoặc công cụ dành cho lập trình viên (Nextra, Docusaurus)
- Blog kỹ thuật mà bạn là lập trình viên kiểm soát toàn bộ nội dung
- Các tutorial tương tác với trình soạn thảo code trực tiếp hoặc demo nhúng
- Các landing page kết hợp nội dung văn xuôi với UI component tùy chỉnh
Khi Nào KHÔNG Nên Dùng MDX
- Nội dung từ người dùng: MDX biên dịch thành JavaScript và thực thi nó. Render MDX không đáng tin là một lỗ hổng bảo mật nghiêm trọng — nó cho kẻ tấn công khả năng thực thi code tùy ý.
- Blog đơn giản không cần component: Nếu bài viết của bạn chỉ là văn bản, tiêu đề, và code block, Markdown thuần với pipeline remark/rehype tốt thì đơn giản và nhanh hơn.
- Nội dung do CMS điều khiển mà biên tập viên không phải lập trình viên: Biên tập viên không kỹ thuật sẽ thấy cú pháp MDX khó hiểu. Markdoc hoặc headless CMS phù hợp hơn.
Các Pattern Thực Tế
Pattern 1: Inject Component Tĩnh (Không Cần Import)
Yêu cầu mỗi file MDX import các component nó dùng tạo ra chi phí bảo trì — nếu bạn đổi tên component, bạn phải cập nhật mọi file. Pattern tốt hơn là inject các component thường dùng trên toàn cục:
// Tất cả file MDX giờ có thể dùng <Callout>, <Note>, <Warning>, <CodeTabs>
// mà không cần một câu lệnh import nào trong file MDX
const globalComponents = {
Callout,
Note,
Warning,
CodeTabs,
}
<MDXRemote source={content} components={globalComponents} />Đây là cách tiếp cận được khuyến nghị cho các component của design system xuất hiện trong nhiều bài viết.
Pattern 2: Code Block Hiển Thị Tên File
rehype-pretty-code hỗ trợ một chuỗi metadata sau identifier ngôn ngữ. Bạn có thể dùng điều này để hiển thị tên file phía trên code block. Thêm filename="your-file.ts" ngay sau tên ngôn ngữ trên dòng mở fence:
export async function getPostBySlug(slug: string) {
// Implementation ở đây — dòng fence phía trên sẽ là:
// ```typescript filename="lib/posts.ts"
}Thuộc tính filename được trích xuất bởi rehype-pretty-code và thêm vào như thuộc tính data trên phần tử <pre>, mà bạn có thể tạo kiểu bằng CSS để hiển thị như tab hoặc nhãn.
Pattern 3: Lazy Loading Component Nặng
Các component tương tác — biểu đồ, code playground, 3D renderer — có thể thêm JavaScript đáng kể vào client bundle của bạn. Lazy load chúng để giữ tốc độ tải trang ban đầu:
import dynamic from 'next/dynamic'
const HeavyChart = dynamic(() => import('../components/HeavyChart'), {
loading: () => <p>Đang tải biểu đồ...</p>,
ssr: false, // Tắt server rendering cho component chỉ chạy trên browser
})
<HeavyChart data={salesData} />Với ssr: false, component bị loại khỏi server render hoàn toàn và chỉ được tải trong browser sau khi hydration.
Pattern 4: MDX với Data Fetching (Next.js RSC)
Trong Next.js App Router, bất kỳ component nào không có 'use client' đều là Server Component mặc định. Điều này có nghĩa là MDX component của bạn có thể fetch dữ liệu trực tiếp — không cần state phía client, không có useEffect, không có spinner loading:
// components/LiveStats.tsx — Server Component (không cần 'use client')
async function LiveStats() {
// Fetch này chạy trên server tại build time hoặc request time
const stats = await fetch('https://api.example.com/stats', {
next: { revalidate: 3600 }, // Revalidate mỗi giờ
}).then(r => r.json())
return (
<div className="stats-card">
<span>Người dùng đang hoạt động: {stats.activeUsers}</span>
</div>
)
}Bạn có thể dùng <LiveStats /> trong bất kỳ file MDX nào, và dữ liệu sẽ được fetch trên server tại build time hoặc theo request — không có JavaScript nào được gửi đến browser cho logic data fetching.
Cân Nhắc Bảo Mật
MDX là một công cụ mạnh mẽ, nhưng sức mạnh của nó đi kèm với một caveat bảo mật quan trọng: MDX biên dịch văn bản nguồn thành JavaScript có thể thực thi. Đây không phải vấn đề khi bạn kiểm soát source — nhưng đây là lỗ hổng bảo mật nghiêm trọng nếu bạn render nội dung từ nguồn không đáng tin.
// ❌ NGUY HIỂM — không bao giờ làm điều này với nội dung do người dùng gửi
const userComment = await db.getComment(id) // Văn bản do người dùng gửi
<MDXRemote source={userComment} /> // Điều này thực thi JS của người dùng!
// ✅ An toàn — chỉ render MDX từ repository nội dung của bạn
const { content } = await getPostBySlug(slug, locale) // Nội dung từ git repo của bạn
<MDXRemote source={content} /> // An toàn để renderCuộc tấn công rất đơn giản: người dùng độc hại gửi nội dung MDX chứa thẻ <script> hoặc biểu thức JavaScript đánh cắp cookie, redirect browser, hoặc thực thi code phía server.
Với nội dung do người dùng tạo, hãy dùng các giải pháp thay thế an toàn hơn:
- Markdoc — an toàn theo thiết kế, hạn chế nội dung theo schema tag đã định nghĩa mà không có JS tùy ý
- react-markdown — chỉ render Markdown, không thực thi JSX nào cả
- rehype-sanitize — nếu bạn phải cho phép một số HTML, plugin này loại bỏ các thuộc tính và phần tử nguy hiểm
Quy tắc chung: MDX dành cho nội dung của bạn. Bất cứ thứ gì được tạo bởi người dùng bên ngoài nên dùng định dạng sandboxed.
Mẹo Tối Ưu Hiệu Năng
1. Cache Compiled MDX
Biên dịch MDX bao gồm parsing, AST transformation, và code generation — không hề rẻ. Trong Next.js App Router, hàm cache() của React memoize kết quả cho mỗi bộ argument duy nhất:
import { cache } from 'react'
// Kết quả biên dịch được cache theo cặp (slug, locale) trong một request
// Với static generation, điều này tránh biên dịch lại cùng một bài viết nhiều lần
export const getPostBySlug = cache(async (slug: string, locale: string) => {
const { frontmatter, content } = await compilePost(slug, locale)
return { frontmatter, content }
})Để cache liên tục qua các request, hãy xem xét unstable_cache từ Next.js hoặc key-value store như Redis nếu độ trễ biên dịch là bottleneck.
2. Static Generation
Với Next.js, bạn có thể generate tất cả trang bài viết tại build time bằng generateStaticParams. Điều này có nghĩa là không có biên dịch runtime — tất cả MDX được biên dịch một lần trong quá trình build và phục vụ như HTML tĩnh:
// app/[locale]/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getAllPosts('en')
// Trả về [{ slug: 'post-1' }, { slug: 'post-2' }, ...]
return posts.map(post => ({ slug: post.slug }))
}Kết hợp với ISR (Incremental Static Regeneration), bạn có thể revalidate từng bài viết theo yêu cầu khi nội dung thay đổi — mà không cần rebuild toàn bộ site.
3. Kích Thước Bundle
Mỗi component bạn import trong file MDX (hoặc inject toàn cục) có thể làm tăng kích thước client bundle. Giảm thiểu bằng cách:
- Ưu tiên Server Component — chúng chạy trên server và không ship JavaScript client nào
- Dùng dynamic import (
next/dynamic) cho các component nặng và tương tác - Giữ các component
'use client'nhỏ và tập trung - Kiểm tra bundle với
@next/bundle-analyzer
Mục tiêu là có tính tương tác phong phú ở nơi bạn cần mà không phải trả giá JavaScript ở khắp nơi.
Tóm Tắt
MDX lấp đầy một niche cụ thể: nội dung do lập trình viên kiểm soát cần React component. Đây là công cụ phù hợp khi các bài blog hay trang tài liệu của bạn cần nhiều hơn là văn bản tĩnh — khi bạn muốn demo trực tiếp, callout được tạo kiểu, bảng tương tác, hoặc biểu đồ nhúng bên cạnh văn xuôi.
Những điểm chính cần nhớ:
✅ MDX = Markdown + JSX — sự tiện lợi của văn xuôi cộng với khả năng tái sử dụng component
✅ Dùng remark plugin cho Markdown transformation, rehype plugin cho HTML/AST transformation
✅ next-mdx-remote là giải pháp hàng đầu cho blog Next.js có nội dung ngoài thư mục app/
✅ Ghi đè các phần tử HTML mặc định trên toàn cục qua mdx-components.tsx để tạo kiểu nhất quán
✅ Không bao giờ render nội dung người dùng không đáng tin như MDX — nó thực thi JavaScript tùy ý
✅ Cache compiled MDX và dùng static generation để đạt hiệu năng tối đa
Nếu bạn đang xây dựng blog cá nhân với Next.js, MDX cho bạn sự linh hoạt rất lớn với độ khó học tương đối nhỏ. Bắt đầu với những điều cơ bản — một component Callout tùy chỉnh và syntax highlighting — rồi thêm độ phức tạp chỉ khi nội dung của bạn thực sự cần đến nó.
Bài Viết Liên Quan
Bài viết liên quan:
📬 Đăng ký nhận bản tin
Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.
Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.
💬 Comments
Đăng nhập để bình luận
Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.