Quay lại danh sách bài viết

Xây dựng hệ thống Blog hiện đại với Markdown, MDX và tư duy sản phẩm

Xuất bản ngày
bởi Nguyễn Công Dũng
blogmarkdownmdxfrontendreactnextjscontentproduct-thinking

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, MarkdownMDX đã 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:

Markdown
# Tiêu đề
**In đậm** *In nghiêng*
- Item 1
- Item 2

Markdown 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ụ:

Markdown
<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íMarkdownMDX
Dễ học✅ Rất dễ⚠️ Cần biết React
Tính động❌ Không✅ Có
Khả năng mở rộngThấpRấ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ự

  1. Xác định audience
  2. Xây dựng outline
  3. Viết nháp
  4. Review
  5. Publish
  6. Đo lường hiệu quả

Inline code

Khi làm việc với MDX, bạn thường sẽ gặp:

  • remark
  • rehype
  • contentlayer
  • next-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)

TypeScript
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

JavaScript
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

src/components/BlogPost.tsx
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

TypeScript
// Đâ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}:

TypeScript
// 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)

TypeScript
// 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

TypeScript
// 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):

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

TypeScript
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: Math

An intrinsic object that provides basic mathematics functionality and constants.

Math
.(method) Math.floor(x: number): number

Returns the greatest integer less than or equal to its numeric argument.

@paramx A numeric expression.
floor
(var Math: Math

An intrinsic object that provides basic mathematics functionality and constants.

Math
.(method) Math.random(): number

Returns 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: User
newUser
= function createUser(name: string, email: string): UsercreateUser('Alice', 'alice@example.com');
// Hover over type để xem inferred type const
const userName: string
userName
= const newUser: UsernewUser.(property) User.name: stringname;

Ví dụ 2: Type Inference với Generics

TypeScript
// 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

TypeScript
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

TypeScript
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: string
data
= (parameter) result: Successresult.(property) data: stringdata;
return const data: stringdata.(method) String.toUpperCase(): string

Converts all the alphabetic characters in a string to uppercase.

toUpperCase
();
} else { // TypeScript biết đây là Error type const
const error: string
error
= (parameter) result: Errorresult.(property) error: stringerror;
namespace console var console: Console

The 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
@seesource
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.

@sincev0.1.100
error
(const error: stringerror);
} }

Ví dụ 5: Utility Types

TypeScript
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 Date

Enables basic storage and retrieval of dates and times.

Date
;
(property) BlogPost.updatedAt: DateupdatedAt: interface Date

Enables 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

TypeScript
// 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 : T

Recursively 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

TypeScript
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: ReadonlyUser
user
: 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]

TypeScript
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 —]

TypeScript
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]

TypeScript
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

TypeScript
// 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

TypeScript
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: any
data
= 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

  • title
  • description
  • slug
  • heading 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