Back to blog

Xây Dựng Blog Cá Nhân — Phase 2: Render MDX Theo Yêu Cầu

nextjsmdxreacttypescriptblog
Xây Dựng Blog Cá Nhân — Phase 2: Render MDX Theo Yêu Cầu

Đây là Phase 2 trong series Xây Dựng Blog Cá Nhân. Ở Phase 1, bạn đã có skeleton Next.js 16 + ShadCN/UI chạy ngon lành rồi. Giờ mình sẽ gắn tầng nội dung vào — viết bài bằng file MDX, mỗi lần có người truy cập thì server compile và render thành trang React hoàn chỉnh, code block tô màu đẹp như VS Code.

Series: Xây Dựng Blog Cá Nhân — Lộ Trình Toàn Bộ
Trước đó: Phase 1 — Next.js 16 + ShadCN/UI Setup
Tiếp theo: Phase 3 — PostgreSQL + Drizzle ORM
Source code: github.com/chanhlt/my-blog — branch personal-blog-phase-2


MDX là gì?

MDX = Markdown + JSX. Nôm na là bạn viết Markdown như bình thường (heading, code block, danh sách, link), nhưng muốn chèn component React chỗ nào cũng được:

# Hello World
 
Đây là đoạn văn bình thường.
 
<CustomAlert type="warning">
  Đây là component React nằm trong Markdown!
</CustomAlert>
 
```bash
npm install next-mdx-remote
```

Tại sao chọn MDX cho blog?

  • Cú pháp quen thuộc — viết bài bằng Markdown thôi, không cần CMS rườm rà
  • Có cả sức mạnh React — muốn nhúng biểu đồ tương tác, callout box, tab gì cũng được
  • Workflow Git — bài viết nằm trong repo, version control cùng code luôn
  • Tương thích rộng — file .mdx chạy được với bất kỳ tool nào hỗ trợ MDX

Render Theo Yêu Cầu vs Sinh Tĩnh

Next.js cho bạn hai cách chính để render nội dung:

Tính năngSinh Tĩnh (SSG)Theo Yêu Cầu (Server)
Khi nào compileLúc buildLúc có request
Cập nhật nội dungPhải rebuildNgay lập tức — sửa file, refresh
Thời gian buildTăng theo số bàiKhông đổi
Phù hợp vớiTrang marketing, docsBlog hay cập nhật
API Next.jsgenerateStaticParams()export const dynamic = 'force-dynamic'

Mình chọn render theo yêu cầu vì:

  1. Không cần rebuild — sửa file MDX, F5 trình duyệt, thấy ngay thay đổi
  2. Deploy đơn giản — đăng bài mới không cần build lại cả site
  3. Hợp với self-hosted — VPS của bạn thừa sức compile MDX mỗi request
  4. Dễ chuyển đổi — mai mốt cần SSG thì thêm generateStaticParams() là xong

Cài Đặt Dependencies

Cần sáu package cho pipeline MDX:

npm install next-mdx-remote gray-matter remark-gfm rehype-pretty-code rehype-slug shiki
PackageCông dụng
next-mdx-remoteCompile và render MDX trong React Server Components
gray-matterParse frontmatter YAML (title, date, tags) từ file MDX
remark-gfmGitHub Flavored Markdown — bảng, task list, gạch ngang
rehype-pretty-codeTô màu syntax cho code block (dùng Shiki bên trong)
rehype-slugTự động tạo id cho heading để làm anchor link
shikiEngine tô màu syntax đứng sau rehype-pretty-code

Cấu Trúc Thư Mục Nội Dung

Tạo thư mục chứa bài viết MDX:

mkdir -p content/posts

Cấu trúc project giờ trông thế này:

my-blog/
├── app/
│   ├── layout.tsx
│   ├── page.tsx
│   └── blog/
│       ├── page.tsx          # Trang danh sách bài
│       └── [slug]/
│           └── page.tsx      # Trang chi tiết bài
├── components/
│   ├── Header.tsx
│   └── Footer.tsx
├── content/
│   └── posts/                # File MDX nằm đây
│       ├── hello-world.mdx
│       └── my-second-post.mdx
├── lib/
│   ├── mdx.ts               # MDX compiler config (dùng React.createElement, không có JSX)
│   └── posts.ts              # Đọc/liệt kê bài từ đĩa
└── package.json

Ghi chú: Phase sau mình sẽ thêm song ngữ với content/posts/en/ và content/posts/vi/. Giờ cứ giữ đơn giản một thư mục phẳng trước đã.


Viết Bài MDX Đầu Tiên

Tạo file content/posts/hello-world.mdx:

---
title: "Hello World — Bài Viết Đầu Tiên"
description: "Bài test ngắn để kiểm tra pipeline MDX hoạt động từ đầu đến cuối."
date: "2026-02-28"
tags: ["intro", "blog"]
---
 
Chào mừng đến blog của tôi! Bài viết này chứng minh pipeline MDX đã hoạt động.
 
## Tại Sao Tôi Xây Cái Này
 
Tôi muốn một blog mà:
 
- Nằm trong Git repo
- Dùng Markdown để viết
- Hỗ trợ tô màu code ngay từ đầu
 
## Ví Dụ Code
 
Đây là một hàm TypeScript đơn giản:
 
```typescript
function greet(name: string): string {
  return `Hello, ${name}!`
}
 
console.log(greet('World'))
```
 
## Tiếp Theo
 
Ở phase tiếp theo mình sẽ thêm database với Drizzle ORM cho view count và subscription.

Giải Thích Frontmatter

Khối YAML nằm giữa hai dòng --- gọi là frontmatter — chứa metadata của bài viết:

---
title: "Hello World"           # Hiển thị làm <h1> của trang
description: "Bài test ngắn..."  # Dùng trong thẻ <meta> và card bài viết
date: "2026-02-28"             # Ngày xuất bản (YYYY-MM-DD)
tags: ["intro", "blog"]        # Để lọc và hiển thị
---

gray-matter sẽ parse khối này thành object JavaScript, tách riêng khỏi phần nội dung MDX bên dưới.


Định Nghĩa TypeScript Types cho Bài Viết

Tạo lib/posts.ts — file này lo việc đọc MDX từ ổ đĩa và trả về dữ liệu bài viết có type rõ ràng:

// lib/posts.ts
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
 
// Interface TypeScript cho metadata bài viết
export interface PostMeta {
  title: string
  description: string
  date: string
  tags: string[]
  image?: string
  slug: string
}
 
// Bài viết đầy đủ = metadata + nội dung MDX thô
export interface Post extends PostMeta {
  content: string
}

Có type rồi thì autocomplete và type checking chạy khắp nơi — sai là IDE báo đỏ ngay.


Đọc Tất Cả Bài Viết

Thêm hàm getAllPosts() vào lib/posts.ts:

// lib/posts.ts (tiếp)
 
const POSTS_DIR = path.join(process.cwd(), 'content/posts')
 
function formatDate(date: Date | string): string {
  const d = new Date(date)
  return d.toLocaleDateString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  })
}
 
export function getAllPosts(): PostMeta[] {
  // 1. Đọc tất cả tên file .mdx từ thư mục posts
  const filenames = fs.readdirSync(POSTS_DIR)
    .filter((f) => f.endsWith('.mdx'))
 
  // 2. Parse frontmatter từ mỗi file
  const posts = filenames.map((filename) => {
    const slug = filename.replace(/\.mdx$/, '')
    const fullPath = path.join(POSTS_DIR, filename)
    const fileContents = fs.readFileSync(fullPath, 'utf8')
    const { data } = matter(fileContents)
 
    return {
      slug,
      title: data.title,
      description: data.description,
      date: formatDate(data.date),
      tags: data.tags || [],
      image: data.image,
    }
  })
 
  // 3. Sắp xếp theo ngày (mới nhất trước)
  return posts.sort((a, b) =>
    new Date(b.date).getTime() - new Date(a.date).getTime()
  )
}

Luồng xử lý:

  1. Đọc thư mục — fs.readdirSync() liệt kê tất cả file .mdx
  2. Parse từng file — gray-matter() tách frontmatter khỏi nội dung
  3. Xây metadata — trích title, description, date, tags từ frontmatter
  4. Sắp theo ngày — bài mới nhất hiện trước

Đọc Một Bài Viết Theo Slug

Tiếp tục thêm getPostBySlug() vào cùng file:

// lib/posts.ts (tiếp)
 
export function getPostBySlug(slug: string): Post | null {
  const fullPath = path.join(POSTS_DIR, `${slug}.mdx`)
 
  // Trả về null nếu file không tồn tại
  if (!fs.existsSync(fullPath)) {
    return null
  }
 
  const fileContents = fs.readFileSync(fullPath, 'utf8')
  const { data, content } = matter(fileContents)
 
  return {
    slug,
    title: data.title,
    description: data.description,
    date: formatDate(data.date),
    tags: data.tags || [],
    image: data.image,
    content, // Chuỗi MDX thô — sẽ compile sau
  }
}

Điểm khác so với getAllPosts(): hàm này trả thêm field content — chuỗi MDX thô mà lát nữa mình sẽ compile thành React component.


Cấu Hình MDX Compiler

Tạo lib/mdx.ts — file này ở trong lib/ vì không chứa JSX. Custom component được đăng ký bằng React.createElement() thay vì JSX, giữ file là TypeScript thuần:

Không cần JSX trong lib/mdx.ts: Thay vì viết JSX <Callout>, dùng React.createElement(Callout, props). Kết quả runtime giống nhau, nhưng file giữ được extension .ts và nằm đúng chỗ trong lib/ — nơi dành cho utility code. Định nghĩa component thật (có JSX) nằm riêng trong components/.

// lib/mdx.ts
import React from 'react'
import { compileMDX } from 'next-mdx-remote/rsc'
import rehypePrettyCode from 'rehype-pretty-code'
import rehypeSlug from 'rehype-slug'
import remarkGfm from 'remark-gfm'
 
// Tùy chọn rehype-pretty-code (tô màu bằng Shiki)
const prettyCodeOptions = {
  theme: 'github-dark',     // Theme tối cho code block
  keepBackground: true,      // Giữ màu nền của theme
  defaultLang: 'plaintext',  // Ngôn ngữ mặc định khi không chỉ định
}
 
export async function compileMDXContent(source: string) {
  const { content } = await compileMDX({
    source,
    options: {
      parseFrontmatter: true,
      mdxOptions: {
        remarkPlugins: [remarkGfm],
        rehypePlugins: [
          rehypeSlug,
          [rehypePrettyCode, prettyCodeOptions],
        ],
      },
    },
  })
 
  return content
}

Pipeline Xử Lý Plugin

Khi compileMDX() chạy, nội dung MDX sẽ đi qua pipeline sau:

PluginGiai đoạnLàm gì
remark-gfmRemark (AST Markdown)Bật GFM: bảng, task list, gạch ngang, autolink
rehype-slugRehype (AST HTML)Thêm id="my-heading" vào mỗi <h2>, <h3>,… cho anchor link
rehype-pretty-codeRehype (AST HTML)Biến code block thành HTML tô màu bởi Shiki với màu theo theme

Tại Sao next-mdx-remote/rsc?

Import /rsc rất quan trọng — nó có nghĩa MDX được compile trên server dưới dạng React Server Component. Lợi ích:

  • Zero JavaScript phía client cho phần nội dung — nhẹ hẳn
  • Tải trang nhanh hơn — MDX đã là HTML sẵn khi đến trình duyệt
  • Truy cập file system trực tiếp — server component đọc file, query database thoải mái

Trang Liệt Kê Bài Viết

Tạo app/blog/page.tsx — trang liệt kê tất cả bài viết:

// app/blog/page.tsx
import Link from 'next/link'
import { getAllPosts } from '@/lib/posts'
 
export default function BlogPage() {
  const posts = getAllPosts()
 
  return (
    <div className="max-w-3xl mx-auto px-4 py-12">
      <h1 className="text-4xl font-bold mb-8">Blog</h1>
 
      {posts.length === 0 ? (
        <p className="text-muted-foreground">Chưa có bài nào. Quay lại sau nhé!</p>
      ) : (
        <div className="space-y-8">
          {posts.map((post) => (
            <article key={post.slug} className="group">
              <Link href={`/blog/${post.slug}`}>
                <div className="border rounded-lg p-6 transition-colors hover:bg-accent">
                  <time className="text-sm text-muted-foreground">
                    {post.date}
                  </time>
                  <h2 className="text-2xl font-semibold mt-1 mb-2 group-hover:text-primary">
                    {post.title}
                  </h2>
                  <p className="text-muted-foreground">{post.description}</p>
                  {post.tags.length > 0 && (
                    <div className="flex gap-2 mt-3">
                      {post.tags.map((tag) => (
                        <span
                          key={tag}
                          className="px-2 py-1 text-xs rounded-full bg-secondary text-secondary-foreground"
                        >
                          {tag}
                        </span>
                      ))}
                    </div>
                  )}
                </div>
              </Link>
            </article>
          ))}
        </div>
      )}
    </div>
  )
}

Mở http://localhost:3000/blog — bài "Hello World" sẽ hiện ra trong danh sách.


Trang Chi Tiết Bài Viết

Tạo app/blog/[slug]/page.tsx — route động render từng bài:

// app/blog/[slug]/page.tsx
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { getPostBySlug } from '@/lib/posts'
import { compileMDXContent } from '@/lib/mdx'
 
interface PageProps {
  params: Promise<{ slug: string }>
}
 
// Bắt buộc render theo yêu cầu (không sinh tĩnh)
export const dynamic = 'force-dynamic'
 
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const { slug } = await params
  const post = getPostBySlug(slug)
 
  if (!post) {
    return { title: 'Không Tìm Thấy Bài Viết' }
  }
 
  return {
    title: post.title,
    description: post.description,
    openGraph: {
      title: post.title,
      description: post.description,
      type: 'article',
      publishedTime: post.date,
    },
  }
}
 
export default async function PostPage({ params }: PageProps) {
  const { slug } = await params
  const post = getPostBySlug(slug)
 
  if (!post) {
    notFound()
  }
 
  // Compile MDX mỗi request (render theo yêu cầu)
  const content = await compileMDXContent(post.content)
 
  return (
    <article className="max-w-3xl mx-auto px-4 py-12">
      <header className="mb-8">
        <Link
          href="/blog"
          className="inline-flex items-center text-muted-foreground hover:text-foreground mb-6"
        >
          ← Quay lại blog
        </Link>
        <time className="block text-sm text-muted-foreground mb-2">
          {post.date}
        </time>
        <h1 className="text-4xl font-bold mb-4">{post.title}</h1>
        {post.tags.length > 0 && (
          <div className="flex flex-wrap gap-2">
            {post.tags.map((tag) => (
              <span
                key={tag}
                className="px-2 py-1 text-sm rounded-full bg-secondary text-secondary-foreground"
              >
                {tag}
              </span>
            ))}
          </div>
        )}
      </header>
 
      {/* Class prose cho typography đẹp */}
      <div className="prose prose-slate dark:prose-invert max-w-none">
        {content}
      </div>
    </article>
  )
}

Điểm cần chú ý

  1. export const dynamic = 'force-dynamic' — bắt Next.js compile MDX mỗi request, không cache lúc build
  2. params: Promise<{ slug: string }> — Next.js 16 truyền params dạng Promise (khác với 14/15)
  3. compileMDXContent(post.content) — biến chuỗi MDX thô thành React element
  4. Class prose — Tailwind Typography tự style toàn bộ HTML cho đẹp, không cần viết CSS tay

Plugin Tailwind Typography

Class prose đến từ plugin @tailwindcss/typography. Cài vào:

npm install @tailwindcss/typography

Rồi đăng ký vào app/globals.css:

@import "tailwindcss";
@plugin "@tailwindcss/typography";

Lưu ý Tailwind v4: Tailwind v4 load plugin bằng @plugin — không cần tailwind.config.ts. Package @tailwindcss/typography tự đăng ký class prose khi được load.

Vậy là toàn bộ nội dung MDX render ra sẽ có typography đẹp sẵn — heading, paragraph, code block, danh sách, blockquote, bảng đều được style hết.

Dark Mode cho Prose

Class dark:prose-invert đảo màu typography khi ở dark mode. Thêm cạnh prose:

<div className="prose prose-slate dark:prose-invert max-w-none">
  {content}
</div>

Hiểu Luồng Dữ Liệu

Cùng trace xem chuyện gì xảy ra khi ai đó truy cập /blog/hello-world:

Chi tiết từng bước:

  1. Match route — Next.js map /blog/hello-world vào app/blog/[slug]/page.tsx với slug = "hello-world"
  2. Đọc file — getPostBySlug('hello-world') mở content/posts/hello-world.mdx lên
  3. Parse frontmatter — gray-matter tách { title, description, date, tags } ra, trả về phần nội dung MDX thô
  4. Compile MDX — compileMDXContent() chạy pipeline remark/rehype, cho ra React element
  5. Render — React element render trên server (RSC), sinh ra HTML
  6. Response — HTML hoàn chỉnh bay về trình duyệt — zero JavaScript phía client

Tô Màu Syntax với Shiki

rehype-pretty-code dùng Shiki bên dưới — cùng engine tô màu của VS Code luôn. Nghĩa là:

  • Tô màu chính xác — dùng grammar TextMate thật, không phải regex đoán mò
  • Hỗ trợ theme — xài theme VS Code nào cũng được (github-dark, one-dark-pro, dracula,…)
  • Nhiều ngôn ngữ — 200+ ngôn ngữ hỗ trợ sẵn, không cần cài thêm

Code Block Được Tô Màu Như Thế Nào

Khi bạn viết fenced code block trong MDX:

```typescript
const x: number = 42
```

Pipeline sẽ xử lý thế này:

  1. Markdown parser thấy ```typescript → tạo AST node code block với lang: "typescript"
  2. rehype-pretty-code chặn code block → gửi code đến Shiki
  3. Shiki tokenize const x: number = 42 bằng grammar TypeScript → áp màu theme github-dark
  4. Output — element <pre><code> với inline color style cho mỗi token

Kết quả: keyword, string, number, type annotation — mỗi thứ một màu, y chang trong VS Code.

Tô Màu Theo Theme

Muốn code block tự đổi theo dark/light mode của trang? Cấu hình dual theme:

// lib/mdx.ts — tùy chọn: hỗ trợ hai theme
const prettyCodeOptions = {
  theme: {
    dark: 'github-dark',
    light: 'github-light',
  },
  keepBackground: true,
  defaultLang: 'plaintext',
}

Cấu hình này tạo code block với CSS variable phản hồi theo class dark:. Ở đây mình giữ github-dark cho đơn giản — hầu hết blog dev đều dùng code block nền tối bất kể theme trang là gì.


Component MDX Tùy Chỉnh

Đây là "siêu năng lực" của MDX — nhúng React component thẳng vào nội dung. Chỉ cần truyền map components cho compileMDX():

// lib/mdx.ts — với component tùy chỉnh (dùng React.createElement, không có JSX)
import React from 'react'
import { compileMDX } from 'next-mdx-remote/rsc'
 
// Định nghĩa Callout component ở components/Callout.tsx (JSX nằm đó).
// Ở đây trong lib/mdx.ts, mình đăng ký nó bằng React.createElement — không cần JSX.
export async function compileMDXContent(source: string) {
  const { content } = await compileMDX({
    source,
    options: {
      parseFrontmatter: true,
      mdxOptions: {
        remarkPlugins: [remarkGfm],
        rehypePlugins: [
          rehypeSlug,
          [rehypePrettyCode, prettyCodeOptions],
        ],
      },
    },
    components: {
      // React.createElement giữ file không có JSX (.ts extension hợp lệ)
      Callout: (props: any) => React.createElement(Callout, props),
    },
  })
 
  return content
}

Giờ trong file MDX bất kỳ, dùng luôn:

<Callout type="warning">
  Đảm bảo cài đầy đủ dependency trước khi tiếp tục!
</Callout>

Muốn thêm bao nhiêu component tùy chỉnh cũng được — tab, accordion, gallery ảnh, code sandbox nhúng, tuỳ bạn sáng tạo.


GitHub Flavored Markdown Thực Tế

Plugin remark-gfm mở khoá thêm nhiều tính năng Markdown. Xem qua từng cái:

Bảng

| Tính năng | Hỗ trợ |
|-----------|--------|
| Bảng      | ✅     |
| Task list | ✅     |
| Gạch ngang | ✅   |

Task List

- [x] Cài dependency
- [x] Tạo thư mục nội dung
- [ ] Viết bài đầu tiên
- [ ] Deploy lên production

Gạch Ngang

~~Đoạn văn này bị gạch ngang~~
Truy cập https://nextjs.org để xem tài liệu.

Tất cả đều render ngon lành vì remark-gfm biến chúng thành HTML chuẩn trước khi rehype xử lý tiếp.


Plugin rehype-slug tự động thêm id vào mỗi heading:

<!-- Input: ## My Heading -->
<!-- Output: <h2 id="my-heading">My Heading</h2> -->

Nhờ vậy bạn có thể:

  • Link thẳng vào section — chia sẻ https://yourblog.com/blog/my-post#my-heading
  • Xây mục lục (ToC) — trích xuất heading ID để tạo table of contents
  • Click heading lấy permalink — tiện cho người đọc muốn bookmark section cụ thể

Lỗi Thường Gặp và Cách Khắc Phục

1. "Module not found: gray-matter"

Chắc chắn đã cài đủ dependency chưa:

npm install next-mdx-remote gray-matter remark-gfm rehype-pretty-code rehype-slug shiki

2. Code block không được tô màu

Kiểm tra code block đã có language tag chưa:

```typescript   ← Phải chỉ định ngôn ngữ
const x = 42
```

Thiếu language tag thì rehype-pretty-code sẽ fallback về defaultLang (mặc định là plaintext).

3. Frontmatter hiện ra dạng text thô

Nếu thấy --- và YAML hiện ra trong trang, kiểm tra:

  • parseFrontmatter: true đã được đặt trong tùy chọn compileMDX()
  • Bạn đang truyền post.content (không có frontmatter), không phải chuỗi file thô

gray-matter đã lo tách frontmatter rồi — đừng parse hai lần.

4. Style "prose" không có tác dụng

Cài plugin typography và import vào CSS:

npm install @tailwindcss/typography
/* app/globals.css */
@import "tailwindcss";
@plugin "@tailwindcss/typography";

5. Lỗi hydration mismatch

Nếu thấy lỗi "Text content does not match", kiểm tra:

  • MDX không chứa code browser-only (kiểu window, document)
  • Component dùng trong MDX phải là Server Component, hoặc wrap đúng với 'use client'
  • Đã đặt export const dynamic = 'force-dynamic' trên trang chưa

Tham Chiếu File Đầy Đủ

Tổng hợp tất cả file tạo mới hoặc sửa trong phase này:

FileCông dụng
content/posts/hello-world.mdxBài test đầu tiên với frontmatter
lib/posts.tsgetAllPosts() và getPostBySlug() — đọc MDX từ đĩa
lib/mdx.tscompileMDXContent() — MDX compiler + registry custom component (không JSX, dùng createElement)
app/blog/page.tsxTrang danh sách blog — hiện tất cả bài
app/blog/[slug]/page.tsxTrang chi tiết bài — render MDX theo yêu cầu
app/globals.cssThêm plugin @tailwindcss/typography

Tóm Tắt

Xong phase này, bạn đã:

✅ Hiểu MDX là gì và tại sao nó hoàn hảo cho blog lập trình
✅ Chọn render theo yêu cầu thay vì sinh tĩnh để cập nhật nội dung tức thì
✅ Cài next-mdx-remote, gray-matter, remark-gfm, rehype-pretty-code, và rehype-slug
✅ Tạo thư mục content/posts/ và viết bài MDX đầu tiên
✅ Định nghĩa TypeScript types cho PostMeta và Post
✅ Xây getAllPosts() và getPostBySlug() để đọc bài từ đĩa
✅ Cấu hình MDX compiler với plugin remark/rehype và tô màu syntax Shiki
✅ Xây trang danh sách blog (/blog) và trang chi tiết bài (/blog/[slug])
✅ Thêm Tailwind Typography cho prose styling đẹp

Tiếp theo — Phase 3: PostgreSQL + Drizzle ORM Mình sẽ gắn database vào blog — view count, subscriber newsletter, comment — dùng Drizzle ORM kết nối PostgreSQL chạy trong Docker.


Mục Lục Series

BàiTiêu đềTrạng thái
BLOG-1Xây Dựng Blog Cá Nhân — Lộ Trình✅ Hoàn thành
BLOG-2Phase 1: Khởi Tạo Project — Next.js 16 + ShadCN/UI✅ Hoàn thành
BLOG-3Phase 2: Render MDX Theo Yêu Cầu✅ Bạn đang ở đây
BLOG-4Phase 3: PostgreSQL + Drizzle ORM✅ Hoàn thành
BLOG-5Phase 4: Tags, Search & Pagination✅ Hoàn thành
BLOG-6Phase 5: Docker Compose✅ Hoàn thành
BLOG-7Phase 6: Deploy lên Ubuntu VPS trên Hostinger✅ Hoàn thành
BLOG-8Phase 7: Thiết Lập Domain trên Hostinger✅ Hoàn thành

📬 Đă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.