Xây dựng hệ thống Blog hiện đại với Markdown, MDX và tư duy sản phẩm
Xây dựng hệ thống Blog hiện đại với Markdown, MDX và tư duy sản phẩm
Trong vài năm trở lại đây, Markdown và MDX đã trở thành lựa chọn phổ biến cho việc xây dựng hệ thống blog, documentation và knowledge base. Không chỉ vì sự đơn giản, mà còn vì khả năng mở rộng linh hoạt, thân thiện với developer và phù hợp với tư duy sản phẩm hiện đại.
Bài viết này nhằm mục tiêu:
- Làm rõ bản chất của Markdown và MDX
- Phân tích cách áp dụng vào hệ thống blog
- Chia sẻ tư duy thiết kế blog như một product, không chỉ là nơi viết bài
Tổng quan về Markdown và MDX
Markdown là gì?
Markdown là một ngôn ngữ đánh dấu nhẹ (lightweight markup language), cho phép bạn viết nội dung với cú pháp đơn giản nhưng có thể render thành HTML đầy đủ.
Ví dụ Markdown cơ bản:
# Tiêu đề
**In đậm** *In nghiêng*
- Item 1
- Item 2Markdown phù hợp cho:
- Blog cá nhân
- Tài liệu kỹ thuật
- README
- Ghi chú nội bộ
MDX là gì và vì sao nó mạnh hơn Markdown?
MDX = Markdown + JSX
Điểm khác biệt cốt lõi:
- Markdown chỉ hiển thị nội dung tĩnh
- MDX cho phép nhúng component React trực tiếp trong bài viết
Ví dụ:
<MyCustomComponent filePath="Hello MDX" />Điều này mở ra khả năng:
- Chèn chart
- Embed video
- Component tương tác
- Callout, Alert, Tabs, Accordion
So sánh Markdown và MDX
| Tiêu chí | Markdown | MDX |
|---|---|---|
| Dễ học | ✅ Rất dễ | ⚠️ Cần biết React |
| Tính động | ❌ Không | ✅ Có |
| Khả năng mở rộng | Thấp | Rất cao |
| Phù hợp blog cá nhân | ✅ | ✅ |
| Phù hợp product docs | ⚠️ | ✅ |
Kết luận ngắn: Nếu blog của bạn chỉ để viết → Markdown đủ dùng. Nếu blog là một phần của sản phẩm → MDX là lựa chọn chiến lược.
Thiết kế blog dưới góc nhìn Product Thinking
Blog không chỉ là nơi viết bài
Một sai lầm phổ biến là xem blog chỉ như:
“Một chỗ để đăng bài viết.”
Trong thực tế, blog là:
- Kênh giáo dục người dùng
- Kênh SEO dài hạn
- Kênh xây dựng trust
- Một phần của user journey
Các câu hỏi product cần đặt ra
1. Ai là người đọc?
- Developer junior?
- Senior engineer?
- Founder?
- Người không chuyên kỹ thuật?
2. Họ đọc để làm gì?
- Học kiến thức?
- Giải quyết vấn đề?
- So sánh giải pháp?
- Ra quyết định?
3. Hành động tiếp theo là gì?
- Đọc bài khác?
- Subscribe?
- Dùng sản phẩm?
- Contact?
Cấu trúc một bài blog chất lượng
H1 – Tiêu đề chính
- Rõ ràng
- Có giá trị
- Tránh clickbait rỗng
H2 – Các ý lớn
Mỗi H2 nên:
- Trả lời một câu hỏi lớn
- Có thể đứng độc lập
H3 – H4 – H5 – H6 để làm gì?
H3: Chia nhỏ logic
H4: Đi sâu chi tiết
H5: Edge case, ghi chú kỹ thuật
H6: Rất hiếm dùng – nhưng hữu ích khi test layout
Ví dụ H6 thường dùng cho:
- Footnote
- Meta note
- Internal remark
Ví dụ về các block nội dung thường dùng
Blockquote
Blog tốt không phải là blog viết hay, mà là blog giải quyết được vấn đề thật.
Danh sách không thứ tự
- Markdown dễ viết
- MDX dễ mở rộng
- Nội dung là cốt lõi
- Trải nghiệm đọc là yếu tố sống còn
Danh sách có thứ tự
- Xác định audience
- Xây dựng outline
- Viết nháp
- Review
- Publish
- Đo lường hiệu quả
Inline code
Khi làm việc với MDX, bạn thường sẽ gặp:
remarkrehypecontentlayernext-mdx-remote
Code block (đa dòng)
Ví dụ TypeScript cơ bản
// Component BlogLayout với TypeScript
export const BlogLayout = ({ children }: { children: React.ReactNode }) => {
return (
<article className="prose max-w-none">
{children}
</article>
)
}Ví dụ TypeScript với type annotations (test Twoslash)
interface User {
id: number;
name: string;
email: string;
}
function getUserById(id: number): User | null {
const users: User[] = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' }
];
return users.find(user => user.id === id) || null;
}
const user = getUserById(1);
console.log(user?.name);Ví dụ JavaScript với async/await
async function fetchBlogPosts() {
try {
const response = await fetch('/api/posts');
const posts = await response.json();
return posts;
} catch (error) {
console.error('Failed to fetch posts:', error);
return [];
}
}
fetchBlogPosts().then(posts => {
console.log(`Loaded ${posts.length} posts`);
});Code block với filename
import { MDXRemote } from 'next-mdx-remote/rsc';
import { mdxComponents } from '@/shared/ui/mdx-components';
interface BlogPostProps {
content: string;
frontmatter: {
title: string;
description: string;
};
}
export function BlogPost({ content, frontmatter }: BlogPostProps) {
return (
<article>
<h1>{frontmatter.title}</h1>
<MDXRemote source={content} components={mdxComponents} />
</article>
);
}Code block dài để test word wrap
// Đây là một đoạn code rất dài để test tính năng word wrap toggle
// Bạn có thể click vào icon word wrap ở góc trên bên phải để bật/tắt word wrap
export function processMarkdownContent(
content: string,
options: {
enableSyntaxHighlighting: boolean;
enableTwoslash: boolean;
enableLineNumbers: boolean;
theme: 'light' | 'dark';
}
): ProcessedContent {
const processed: ProcessedContent = {
html: '',
metadata: {},
codeBlocks: []
};
// Parse frontmatter
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (frontmatterMatch) {
processed.metadata = parseFrontmatter(frontmatterMatch[1]);
content = frontmatterMatch[2];
}
// Process code blocks
const codeBlockRegex = /```(\w+)?\n([\s\S]*?)```/g;
let match;
while ((match = codeBlockRegex.exec(content)) !== null) {
const language = match[1] || 'text';
const code = match[2];
processed.codeBlocks.push({
language,
code,
highlighted: options.enableSyntaxHighlighting
? highlightCode(code, language)
: code
});
}
return processed;
}Code block với line highlighting
Để highlight các dòng cụ thể, sử dụng meta string với syntax {số-dòng} hoặc {dòng-bắt-đầu-dòng-kết-thúc}:
// Dòng 2 và dòng 4-6 sẽ được highlight
function createMDXOptions() {
const options = {
remarkPlugins: [remarkGfm, remarkSmartypants],
rehypePlugins: [
[rehypePrettyCode, {
theme: { light: 'github-light', dark: 'github-dark' },
onVisitHighlightedLine(node) {
node.properties.className.push('line', 'highlighted');
}
}]
]
};
return options;
}Giải thích syntax:
{2}- Highlight dòng 2{2,4}- Highlight dòng 2 và 4{2-4}- Highlight dòng 2 đến 4{2,4-6}- Highlight dòng 2 và dòng 4-6
Ví dụ phức tạp với TypeScript generics (test Twoslash)
// Generic function với type constraints
function processContent<T extends { id: string }>(
items: T[],
processor: (item: T) => Promise<T>
): Promise<T[]> {
return Promise.all(items.map(processor));
}
interface BlogPost {
id: string;
title: string;
content: string;
}
const posts: BlogPost[] = [
{ id: '1', title: 'Post 1', content: '...' },
{ id: '2', title: 'Post 2', content: '...' }
];
// TypeScript sẽ infer type từ posts
const processed = await processContent(posts, async (post) => {
return { ...post, processed: true };
});Thêm ví dụ line highlighting với nhiều dòng
// Dòng 1 được highlight
import { createMDXOptions } from './mdx-config';
// Dòng 3-5 được highlight (function definition)
export function renderMDX(content: string) {
const options = createMDXOptions();
return render(content, options);
}
// Dòng 8 được highlight
export default renderMDX;Ví dụ với word highlighting (highlight từng từ cụ thể)
Word highlighting có thể được thực hiện bằng cách sử dụng Twoslash syntax // ^^^ hoặc highlight classes từ rehype-pretty-code. Hiện tại, tính năng này đang được phát triển và sẽ được cập nhật trong tương lai.
Ví dụ với Twoslash (khi enable):
function createMDXOptions() {
return { /* config */ };
}
// ^^^
// Highlight từ "createMDXOptions"Ví dụ với rehype-pretty-code (cần cấu hình thêm):
Word highlighting trong rehype-pretty-code thường được xử lý thông qua CSS classes hoặc custom transformers. Tính năng này sẽ được cải thiện trong các phiên bản tương lai.
Twoslash - TypeScript Type Annotations
Twoslash là công cụ mạnh mẽ giúp hiển thị thông tin kiểu TypeScript trực tiếp trong code blocks. Để sử dụng Twoslash, thêm twoslash vào meta string của code fence.
Ví dụ 1: Type Hover Queries với ^?
Sử dụng ^? để hiển thị type của một biến hoặc expression:
interface interface UserUser {
(property) User.id: numberid: number;
(property) User.name: stringname: string;
(property) User.email: stringemail: string;
(property) User.role: "admin" | "user" | "guest"role: 'admin' | 'user' | 'guest';
}
function function createUser(name: string, email: string): UsercreateUser((parameter) name: stringname: string, (parameter) email: stringemail: string): interface UserUser {
return {
(property) User.id: numberid: var Math: MathAn intrinsic object that provides basic mathematics functionality and constants.
Math.(method) Math.floor(x: number): numberReturns the greatest integer less than or equal to its numeric argument.
floor(var Math: MathAn intrinsic object that provides basic mathematics functionality and constants.
Math.(method) Math.random(): numberReturns a pseudorandom number between 0 and 1.
random() * 1000),
(property) User.name: stringname,
(property) User.email: stringemail,
(property) User.role: "admin" | "user" | "guest"role: 'user'
};
}
const const newUser: UsernewUser = function createUser(name: string, email: string): UsercreateUser('Alice', 'alice@example.com');
// Hover over type để xem inferred type
const const userName: stringuserName = const newUser: UsernewUser.(property) User.name: stringname;
Ví dụ 2: Type Inference với Generics
// Generic function với type constraints
function function wrapInArray<T>(value: T): T[]wrapInArray<(type parameter) T in wrapInArray<T>(value: T): T[]T>((parameter) value: Tvalue: (type parameter) T in wrapInArray<T>(value: T): T[]T): (type parameter) T in wrapInArray<T>(value: T): T[]T[] {
return [(parameter) value: Tvalue];
}
const const numberArray: number[]numberArray = function wrapInArray<number>(value: number): number[]wrapInArray(42);
const const stringArray: string[]stringArray = function wrapInArray<string>(value: string): string[]wrapInArray('hello');
const const objectArray: { id: number; name: string; }[]objectArray = function wrapInArray<{ id: number; name: string; }>(value: { id: number; name: string; }): { id: number; name: string; }[]wrapInArray({ (property) id: numberid: 1, (property) name: stringname: 'Product' });
Ví dụ 3: Complex Type Inference
interface interface PostPost {
(property) Post.id: stringid: string;
(property) Post.title: stringtitle: string;
(property) Post.author: { name: string; email: string; }author: {
(property) name: stringname: string;
(property) email: stringemail: string;
};
}
async function function fetchPosts(): Promise<Post[]>fetchPosts(): interface Promise<T>Represents the completion of an asynchronous operation
Promise<interface PostPost[]> {
const const response: Responseresponse = await function fetch(input: string | URL | Request, init?: RequestInit): Promise<Response> (+1 overload)fetch('/api/posts');
return const response: Responseresponse.(method) Body.json(): Promise<any>json();
}
// TypeScript tự động infer return type
const const posts: Post[]posts = await function fetchPosts(): Promise<Post[]>fetchPosts();
// Truy cập nested properties
const const firstAuthor: { name: string; email: string; }firstAuthor = const posts: Post[]posts[0]?.(property) Post.author: { name: string; email: string; }author;
Ví dụ 4: Union Types và Type Guards
type type Success = { success: true; data: string; }Success = { (property) success: truesuccess: true; (property) data: stringdata: string };
type type Error = { success: false; error: string; }Error = { (property) success: falsesuccess: false; (property) error: stringerror: string };
type type Result = Success | ErrorResult = type Success = { success: true; data: string; }Success | type Error = { success: false; error: string; }Error;
function function processResult(result: Result): string | undefinedprocessResult((parameter) result: Resultresult: type Result = Success | ErrorResult) {
if ((parameter) result: Resultresult.(property) success: booleansuccess) {
// TypeScript biết đây là Success type
const const data: stringdata = (parameter) result: Successresult.(property) data: stringdata;
return const data: stringdata.(method) String.toUpperCase(): stringConverts all the alphabetic characters in a string to uppercase.
toUpperCase();
} else {
// TypeScript biết đây là Error type
const const error: stringerror = (parameter) result: Errorresult.(property) error: stringerror;
namespace console var console: ConsoleThe console module provides a simple debugging console that is similar to the
JavaScript console mechanism provided by web browsers.
The module exports two specific components:
- A
Console class with methods such as console.log(), console.error() and console.warn() that can be used to write to any Node.js stream.
- A global
console instance configured to write to process.stdout and
process.stderr. The global console can be used without importing the node:console module.
Warning: The global console object's methods are neither consistently
synchronous like the browser APIs they resemble, nor are they consistently
asynchronous like all other Node.js streams. See the note on process I/O for
more information.
Example using the global console:
console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr
Example using the Console class:
const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err
console.(method) Console.error(message?: any, ...optionalParams: any[]): void (+1 overload)Prints to stderr with newline. Multiple arguments can be passed, with the
first used as the primary message and all additional used as substitution
values similar to printf(3)
(the arguments are all passed to util.format()).
const code = 5;
console.error('error #%d', code);
// Prints: error #5, to stderr
console.error('error', code);
// Prints: error 5, to stderr
If formatting elements (e.g. %d) are not found in the first string then
util.inspect() is called on each argument and the
resulting string values are concatenated. See util.format()
for more information.
error(const error: stringerror);
}
}Ví dụ 5: Utility Types
interface interface BlogPostBlogPost {
(property) BlogPost.id: stringid: string;
(property) BlogPost.title: stringtitle: string;
(property) BlogPost.content: stringcontent: string;
(property) BlogPost.author: stringauthor: string;
(property) BlogPost.publishedAt: DatepublishedAt: interface DateEnables basic storage and retrieval of dates and times.
Date;
(property) BlogPost.updatedAt: DateupdatedAt: interface DateEnables basic storage and retrieval of dates and times.
Date;
}
// Partial - tất cả properties trở thành optional
type type PartialPost = { id?: string | undefined; title?: string | undefined; content?: string | undefined; author?: string | undefined; publishedAt?: Date | undefined; updatedAt?: Date | undefined; }PartialPost = type Partial<T> = { [P in keyof T]?: T[P] | undefined; }Make all properties in T optional
Partial<interface BlogPostBlogPost>;
// Pick - chọn một số properties
type type PostPreview = { id: string; title: string; author: string; }PostPreview = type Pick<T, K extends keyof T> = { [P in K]: T[P]; }From T, pick a set of properties whose keys are in the union K
Pick<interface BlogPostBlogPost, 'id' | 'title' | 'author'>;
// Omit - loại bỏ một số properties
type type PostWithoutDates = { id: string; title: string; content: string; author: string; }PostWithoutDates = type Omit<T, K extends keyof any> = { [P in Exclude<keyof T, K>]: T[P]; }Construct a type with the properties of T except for those in type K.
Omit<interface BlogPostBlogPost, 'publishedAt' | 'updatedAt'>;
// Record - tạo object type với keys và values cụ thể
type type PostsByAuthor = { [x: string]: BlogPost[]; }PostsByAuthor = type Record<K extends keyof any, T> = { [P in K]: T; }Construct a type with a set of properties K of type T
Record<string, interface BlogPostBlogPost[]>;
Ví dụ 6: Conditional Types
// Conditional type để extract return type
type type ReturnType<T> = T extends (...args: any[]) => infer R ? R : neverReturnType<(type parameter) T in type ReturnType<T>T> = (type parameter) T in type ReturnType<T>T extends (...(parameter) args: any[]args: any[]) => infer (type parameter) RR ? (type parameter) RR : never;
function function getUser(): { id: number; name: string; }getUser() {
return { (property) id: numberid: 1, (property) name: stringname: 'Alice' };
}
type type UserType = { id: number; name: string; }UserType = type ReturnType<T> = T extends (...args: any[]) => infer R ? R : neverReturnType<typeof function getUser(): { id: number; name: string; }getUser>;
// Awaited type để unwrap Promise
type type AwaitedUser = { id: number; name: string; }AwaitedUser = type Awaited<T> = T extends null | undefined ? T : T extends object & { then(onfulfilled: infer F, ...args: infer _): any; } ? F extends (value: infer V, ...args: infer _) => any ? Awaited<V> : never : TRecursively unwraps the "awaited type" of a type. Non-promise "thenables" should resolve to never. This emulates the behavior of await.
Awaited<interface Promise<T>Represents the completion of an asynchronous operation
Promise<type UserType = { id: number; name: string; }UserType>>;
Ví dụ 7: Mapped Types
interface interface UserUser {
(property) User.id: numberid: number;
(property) User.name: stringname: string;
(property) User.email: stringemail: string;
}
// Tạo readonly version
type type ReadonlyUser = { readonly id: number; readonly name: string; readonly email: string; }ReadonlyUser = {
readonly [(type parameter) KK in keyof interface UserUser]: interface UserUser[(type parameter) KK];
};
const const user: ReadonlyUseruser: type ReadonlyUser = { readonly id: number; readonly name: string; readonly email: string; }ReadonlyUser = {
(property) id: numberid: 1,
(property) name: stringname: 'Alice',
(property) email: stringemail: 'alice@example.com'
};
// Không thể modify
// user.name = 'Bob'; // Error!Word Highlighting với Shiki Transformers
Ngoài Twoslash, chúng ta có thể sử dụng các Shiki transformers để highlight code theo nhiều cách khác nhau.
Ví dụ 1: Highlighting Lines với [!code highlight]
function processMarkdown(content: string) {
const lines = content.split('\n');
const processed = lines.map(line => line.trim());
return processed.join('\n');
}Ví dụ 2: Code Diff với [!code ++] và [!code —]
function createUser(name: string, email: string) {
return {
id: Math.random(),
id: crypto.randomUUID(),
name,
email,
createdAt: new Date()
};
}Ví dụ 3: Focus Mode với [!code focus]
interface Config {
apiUrl: string;
timeout: number;
retries: number;
}
function createConfig(): Config {
return {
apiUrl: process.env.API_URL || 'https://api.example.com',
timeout: 5000,
retries: 3
};
}Ví dụ 4: Kết hợp nhiều transformers
// Old implementation
function fetchData(url: string) {
return fetch(url).then(r => r.json());
}
// New implementation with error handling
async function fetchData(url: string) {
try { // [!code ++]
const response = await fetch(url); // [!code ++]
if (!response.ok) { // [!code ++]
throw new Error(`HTTP ${response.status}`); // [!code ++]
} // [!code ++]
return await response.json(); // [!code ++]
} catch (error) { // [!code ++]
console.error('Fetch failed:', error);
throw error;
} // [!code ++]
}Ví dụ 5: Kết hợp Twoslash và Line Highlighting
interface interface ApiResponse<T>ApiResponse<(type parameter) T in ApiResponse<T>T> {
(property) ApiResponse<T>.data: Tdata: (type parameter) T in ApiResponse<T>T;
(property) ApiResponse<T>.error?: string | undefinederror?: string;
}
async function function fetchUser(id: number): Promise<ApiResponse<User>>fetchUser((parameter) id: numberid: number): interface Promise<T>Represents the completion of an asynchronous operation
Promise<interface ApiResponse<T>ApiResponse<interface UserUser>> {
const const response: Responseresponse = await function fetch(input: string | URL | Request, init?: RequestInit): Promise<Response> (+1 overload)fetch(`/api/users/${(parameter) id: numberid}`);
const const data: anydata = await const response: Responseresponse.(method) Body.json(): Promise<any>json();
return const data: anydata;
}
interface interface UserUser {
(property) User.id: numberid: number;
(property) User.name: stringname: string;
}SEO trong blog Markdown / MDX
Những yếu tố không được bỏ qua
titledescriptionslugheading structure (H1-H6)- Internal link
- Thời gian đọc
Tags và Metadata
Tags giúp:
- Phân loại nội dung
- Tạo landing page theo chủ đề
- Cải thiện internal linking
Ví dụ tags:
- frontend
- backend
- devops
- system-design
- career
- mindset
Những sai lầm thường gặp khi làm blog kỹ thuật
Viết cho bản thân, không phải cho người đọc
- Quá nhiều thuật ngữ
- Thiếu ví dụ
- Thiếu ngữ cảnh
Bỏ qua trải nghiệm đọc
- Paragraph quá dài
- Không có spacing
- Không có visual break
Kết luận
Blog hiện đại không chỉ là nơi viết bài, mà là:
- Một hệ thống nội dung
- Một công cụ giáo dục
- Một đòn bẩy tăng trưởng dài hạn
Markdown và MDX chỉ là công cụ. Giá trị thật sự nằm ở:
Tư duy sản phẩm + sự thấu hiểu người đọc
Tài liệu tham khảo
- https://mdxjs.com
- https://commonmark.org
- https://nextjs.org/docs/app/building-your-application/configuring/mdx