Bài 29.5: Bài tập thực hành blog với Next.js

Chào mừng bạn đến với bài thực hành quan trọng trong hành trình làm chủ Front-end của chúng ta! Xây dựng một ứng dụng blog là một case study kinh điển và cực kỳ hữu ích để củng cố kiến thức về một framework mạnh mẽ như Next.js. Blog là nơi chúng ta có thể áp dụng linh hoạt các khái niệm về định tuyến, fetch dữ liệu, render tĩnh (SSG), render phía server (SSR), và nhiều hơn thế nữa.

Hôm nay, chúng ta sẽ cùng nhau đi sâu vào các bước để dựng lên một trang blog cơ bản sử dụng Next.js, tập trung vào việc hiển thị danh sách bài viết và trang chi tiết từng bài viết, sử dụng file Markdown làm nguồn dữ liệu. Đây là một cách tiếp cận phổ biến cho các blog cá nhân hoặc blog có ít người chỉnh sửa.

Tại sao lại chọn Next.js cho blog?

Next.js là lựa chọn tuyệt vời để xây dựng blog vì nhiều lý do:

  • Hiệu suất vượt trội: Nhờ hỗ trợ Server-Side Rendering (SSR) và đặc biệt là Static Site Generation (SSG), các bài viết blog của bạn có thể được pre-render thành các file HTML tĩnh tại thời điểm build. Điều này giúp trang web tải cực nhanh, cải thiện SEO mạnh mẽ và mang lại trải nghiệm người dùng mượt mà.
  • Định tuyến dễ dàng: Hệ thống định tuyến dựa trên file của Next.js giúp việc tạo các trang và đường dẫn cho bài viết trở nên đơn giản và trực quan. Chỉ cần tạo file trong thư mục pages (hoặc app), Next.js sẽ tự động tạo route tương ứng.
  • Phát triển nhanh chóng: Với các tính năng như Hot Module Replacement (HMR), Fast Refresh, và môi trường phát triển được tối ưu hóa, bạn có thể thấy ngay kết quả thay đổi code mà không cần load lại trang, giúp quá trình code trở nên hiệu quả.
  • Linh hoạt: Bạn có thể chọn giữa SSG, SSR, hoặc thậm chí Client-Side Rendering (CSR) cho các phần khác nhau của blog, tùy thuộc vào yêu cầu cụ thể.

Bắt đầu: Khởi tạo Dự án Next.js

Bước đầu tiên luôn là tạo một dự án Next.js mới. Mở terminal và chạy lệnh sau:

npx create-next-app@latest my-nextjs-blog --typescript --eslint

Lệnh này sẽ sử dụng create-next-app để tạo một dự án Next.js mới nhất với TypeScript và ESLint đã được cấu hình sẵn. Bạn sẽ được hỏi một vài câu hỏi về các tùy chọn khác như App Router hay Pages Router, Tailwind CSS... Hãy chọn các tùy chọn phù hợp với bạn (khóa học này đang sử dụng App Router).

Sau khi quá trình tạo dự án hoàn tất, di chuyển vào thư mục dự án:

cd my-nextjs-blog

Tổ chức Nội dung Blog: Sử dụng Markdown

Đối với nhiều blog, đặc biệt là blog cá nhân hoặc blog tài liệu, việc lưu trữ nội dung dưới dạng các file tĩnh là một cách làm hiệu quảdễ quản lý. Markdown (.md) là định dạng phổ biến nhất cho mục đích này nhờ cú pháp đơn giản, dễ viết và dễ dàng chuyển đổi sang HTML.

Chúng ta sẽ tạo một thư mục để chứa tất cả các file Markdown của bài viết. Ví dụ: tạo thư mục posts ở ngang hàng với thư mục src (hoặc ở thư mục gốc của dự án nếu bạn không dùng thư mục src).

my-nextjs-blog/
├── posts/
│   ├── gioi-thieu-nextjs.md
│   ├── xay-dung-layout.md
│   └── bai-viet-moi-nhat.md
└── src/
    └── app/ # Nếu bạn dùng App Router
        ├── page.tsx          # Trang chủ
        ├── layout.tsx        # Layout chung
        └── posts/
            └── [slug]/
                └── page.tsx  # Trang chi tiết bài viết động
    └── ...

Mỗi file Markdown sẽ đại diện cho một bài viết. Tên file (ví dụ: gioi-thieu-nextjs.md) sẽ thường được dùng làm slug (một phần của URL) cho bài viết đó (/posts/gioi-thieu-nextjs).

Để lưu trữ các thông tin metadata của bài viết như tiêu đề, ngày đăng, tác giả, mô tả ngắn, v.v., chúng ta sẽ sử dụng frontmatter. Frontmatter là một khối YAML tùy chọn nằm ở đầu file Markdown, được phân tách bằng ba dấu gạch ngang (---).

Ví dụ về nội dung một file Markdown (posts/gioi-thieu-nextjs.md):

---
title: Giới thiệu về Next.js
date: 2023-10-28
author: FullhouseDev
description: Tìm hiểu những điều cơ bản về Next.js và tại sao nó lại phổ biến.
tags: ['Next.js', 'React', 'Framework']
---

# Next.js là gì?

Next.js là một *framework React* mã nguồn mở được phát triển bởi Vercel. Nó cung cấp các tính năng bổ sung cho React, giúp bạn xây dựng các ứng dụng web *production-ready* một cách dễ dàng và hiệu quả hơn.

## Tại sao nên dùng Next.js?

*   **Server-Side Rendering (SSR) & Static Site Generation (SSG):** Tăng hiệu suất và SEO.
*   **Định tuyến dựa trên file:** Dễ dàng tổ chức các trang.
*   **API Routes:** Tạo API backend ngay trong dự án Next.js.

Nội dung bài viết của bạn sẽ được viết bằng cú pháp Markdown ở đây.

Hiển thị Danh sách Bài viết trên Trang Chủ

Trang chủ của blog (src/app/page.tsx trong App Router, hoặc pages/index.tsx trong Pages Router) sẽ cần hiển thị danh sách tất cả các bài viết. Để làm được điều này, chúng ta cần đọc tất cả các file Markdown trong thư mục posts, trích xuất thông tin frontmatter từ mỗi file và hiển thị chúng.

Để xử lý file system và phân tích frontmatter, chúng ta sẽ cần cài đặt thêm một số thư viện:

npm install gray-matter
# hoặc
yarn add gray-matter
# hoặc
pnpm add gray-matter

gray-matter là một thư viện tuyệt vời để phân tích phần frontmatter từ file Markdown.

Chúng ta nên tạo một vài hàm helper để xử lý logic đọc file, giúp component của chúng ta sạch sẽdễ đọc hơn. Tạo một file ví dụ như src/lib/posts.ts:

// src/lib/posts.ts
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';

const postsDirectory = path.join(process.cwd(), 'posts'); // Đường dẫn tới thư mục chứa bài viết

export interface PostMetaData {
  title: string;
  date: string;
  author?: string; // Có thể có hoặc không
  description?: string;
  tags?: string[];
  [key: string]: any; // Cho phép các thuộc tính frontmatter khác
}

export interface PostItem {
    slug: string;
    metadata: PostMetaData;
}

/**
 * Lấy danh sách tất cả các bài viết (chỉ metadata và slug), sắp xếp theo ngày.
 */
export function getSortedPostsData(): PostItem[] {
  // Lấy tên tất cả các file .md trong thư mục /posts
  const fileNames = fs.readdirSync(postsDirectory);

  const allPostsData = fileNames.map((fileName) => {
    // Loại bỏ ".md" khỏi tên file để lấy slug
    const slug = fileName.replace(/\.md$/, '');

    // Đọc nội dung file markdown dưới dạng string
    const fullPath = path.join(postsDirectory, fileName);
    const fileContents = fs.readFileSync(fullPath, 'utf8');

    // Sử dụng gray-matter để phân tích phần frontmatter
    const matterResult = matter(fileContents);

    // Kết hợp dữ liệu frontmatter với slug
    return {
      slug,
      metadata: matterResult.data as PostMetaData, // Ép kiểu dữ liệu frontmatter
    };
  });

  // Sắp xếp bài viết theo ngày giảm dần
  return allPostsData.sort((a, b) => {
    if (a.metadata.date < b.metadata.date) {
      return 1; // b đứng trước a
    } else {
      return -1; // a đứng trước b
    }
  });
}

// Hàm này sẽ được dùng ở trang chi tiết, sẽ viết sau
// export async function getPostData(slug: string) { ... }

Giải thích: Hàm getSortedPostsData đọc tên tất cả các file trong thư mục posts, sau đó lặp qua từng file. Với mỗi file, nó đọc toàn bộ nội dung, sử dụng gray-matter để phân tách frontmatter và nội dung chính. Chúng ta chỉ lấy phần frontmatter và slug (tên file bỏ .md) rồi trả về một mảng các object chứa slugmetadata, cuối cùng sắp xếp theo ngày.

Bây giờ, chúng ta sử dụng hàm này trong component trang chủ (src/app/page.tsx):

// src/app/page.tsx
import Link from 'next/link';
import { getSortedPostsData } from '@/lib/posts'; // Import hàm helper
import styles from './page.module.css'; // Ví dụ về CSS Modules

export default function Home() {
  // Lấy dữ liệu bài viết. Hàm này chạy ở phía server (trong App Router)
  const allPostsData = getSortedPostsData();

  return (
    <div className={styles.container}> {/* Áp dụng CSS */}
      <h1 className={styles.title}>Blog  nhân của tôi</h1>

      <section className={styles.postsList}>
        <h2>Bài viết gần đây</h2>
        <ul>
          {allPostsData.map(({ slug, metadata }) => (
            <li key={slug}>
              {/* Sử dụng component Link của Next.js để điều hướng */}
              <Link href={`/posts/${slug}`}>
                  {/* Hiển thị tiêu đề */}
                  {metadata.title}
              </Link>
              <br />
              {/* Hiển thị ngày tháng và tác giả */}
              <small className={styles.dateText}>
                Ngày đăng: {metadata.date} {metadata.author && `| Tác giả: ${metadata.author}`}
              </small>
            </li>
          ))}
        </ul>
      </section>
    </div>
  );
}

Giải thích: Component Home (trong App Router) là một Server Component theo mặc định. Nó gọi hàm getSortedPostsData() để lấy dữ liệu tất cả các bài viết. Vì hàm này đọc file system, nó chỉ có thể chạy ở phía server, điều này là hoàn hảo với Server Component. Sau khi có dữ liệu, chúng ta lặp qua mảng allPostsData và hiển thị tiêu đề, ngày tháng dưới dạng các mục danh sách (<li>). Mỗi mục là một liên kết (<Link>) tới trang chi tiết bài viết, sử dụng slug để tạo đường dẫn động /posts/[slug]. Component Link của Next.js tối ưu hóa việc điều hướng client-side.

Xây dựng Trang Chi tiết Bài viết ([slug].tsx)

Mỗi bài viết cần một trang riêng với URL duy nhất (ví dụ: /posts/gioi-thieu-nextjs). Next.js hỗ trợ định tuyến động (Dynamic Routes) để xử lý trường hợp này. Với App Router, chúng ta tạo một thư mục có tên [slug] bên trong thư mục posts trong app (src/app/posts/[slug]). File component trang sẽ là page.tsx bên trong thư mục [slug] đó (src/app/posts/[slug]/page.tsx).

Trang chi tiết bài viết cần làm những việc sau:

  1. Xác định các đường dẫn tĩnh (Static Paths): Đối với SSG, Next.js cần biết trước tất cả các giá trị slug có thể có để pre-render các trang tương ứng tại thời điểm build.
  2. Lấy dữ liệu chi tiết cho một slug cụ thể: Đọc file Markdown tương ứng với slug từ URL, phân tích frontmatter và lấy cả nội dung chính.
  3. Chuyển đổi Markdown sang HTML: Nội dung chính của file Markdown cần được chuyển đổi thành HTML để hiển thị trên trình duyệt.
  4. Render trang: Hiển thị tiêu đề, metadata và nội dung HTML đã chuyển đổi.

Để chuyển đổi Markdown sang HTML, chúng ta sẽ sử dụng thư viện remark và plugin remark-html.

npm install remark remark-html
# hoặc
yarn add remark remark-html
# hoặc
pnpm add remark remark-html

Bây giờ, chúng ta cần bổ sung một hàm helper mới trong src/lib/posts.ts (hoặc tạo file src/lib/post.ts riêng) để lấy dữ liệu chi tiết của một bài viết dựa trên slug:

// src/lib/posts.ts (Thêm hàm mới vào file đã tạo ở trên)
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import { remark } from 'remark';
import html from 'remark-html';

const postsDirectory = path.join(process.cwd(), 'posts');

export interface PostData {
  slug: string;
  metadata: PostMetaData; // Metadata lấy từ frontmatter
  contentHtml: string;   // Nội dung bài viết đã chuyển đổi sang HTML
}

/**
 * Lấy dữ liệu chi tiết (metadata + content HTML) của một bài viết dựa trên slug.
 */
export async function getPostData(slug: string): Promise<PostData> {
  const fullPath = path.join(postsDirectory, `${slug}.md`);
  const fileContents = fs.readFileSync(fullPath, 'utf8');

  // Sử dụng gray-matter để phân tích phần frontmatter và nội dung
  const matterResult = matter(fileContents);

  // Sử dụng remark để chuyển đổi nội dung Markdown sang HTML
  const processedContent = await remark()
    .use(html) // Sử dụng plugin remark-html
    .process(matterResult.content); // Xử lý phần nội dung (không phải frontmatter)
  const contentHtml = processedContent.toString(); // Lấy kết quả dạng string HTML

  // Kết hợp dữ liệu frontmatter, slug và contentHtml
  return {
    slug,
    metadata: matterResult.data as PostMetaData,
    contentHtml,
  };
}

// Giữ nguyên hàm getSortedPostsData đã viết ở trên
// export function getSortedPostsData(): PostItem[] { ... }

Giải thích: Hàm getPostData nhận slug, xây dựng đường dẫn đầy đủ đến file Markdown, đọc file, dùng gray-matter để phân tích. Sau đó, nó dùng remark với plugin remark-html để xử lý phần nội dung (không phải frontmatter) và chuyển đổi nó sang HTML string. Cuối cùng, nó trả về một object chứa slug, metadatacontentHtml.

Bây giờ, tạo file component cho trang chi tiết bài viết (src/app/posts/[slug]/page.tsx):

// src/app/posts/[slug]/page.tsx
import { getSortedPostsData, getPostData, PostData } from '@/lib/posts'; // Import các hàm helper
import styles from './page.module.css'; // Ví dụ về CSS Modules

// Hàm này báo cho Next.js biết cần tạo ra các đường dẫn (paths) tĩnh nào
// Dữ liệu trả về sẽ được dùng bởi generateStaticParams
export async function generateStaticParams() {
  const posts = getSortedPostsData(); // Lấy danh sách tất cả bài viết

  // generateStaticParams cần trả về một mảng các object,
  // mỗi object chứa các parameter (ở đây là slug)
  return posts.map((post) => ({
    slug: post.slug,
  }));
}

// Kiểu dữ liệu cho props mà component nhận được
interface PostPageProps {
  params: {
    slug: string; // Parameter động từ URL
  };
}

// Component trang chi tiết bài viết (Server Component)
export default async function PostPage({ params }: PostPageProps) {
  const { slug } = params; // Lấy slug từ URL parameters

  // Lấy dữ liệu chi tiết cho bài viết cụ thể
  const postData: PostData = await getPostData(slug); // Hàm này chạy ở phía server

  return (
    <article className={styles.article}> {/* Áp dụng CSS */}
      <h1 className={styles.title}>{postData.metadata.title}</h1>
      <p className={styles.date}>
        <small>Ngày đăng: {postData.metadata.date}</small>
        {postData.metadata.author && <small> | Tác giả: {postData.metadata.author}</small>}
      </p>
      {/* Hiển thị nội dung bài viết đã được chuyển đổi sang HTML */}
      {/* !! CẨN THẬN: dangerouslySetInnerHTML có thể gây XSS nếu nguồn không đáng tin cậy !! */}
      {/* Tuy nhiên, với nội dung tĩnh do bạn kiểm soát, đây là cách thông thường */}
      <div
        dangerouslySetInnerHTML={{ __html: postData.contentHtml }}
        className={styles.content} // Áp dụng CSS cho nội dung
      />
    </article>
  );
}

// Export metadata (tùy chọn cho SEO)
// export async function generateMetadata({ params }: PostPageProps) {
//     const postData: PostData = await getPostData(params.slug);
//     return {
//         title: postData.metadata.title,
//         description: postData.metadata.description,
//         keywords: postData.metadata.tags?.join(', '),
//     };
// }

Giải thích:

  1. generateStaticParams(): Hàm này là tính năng của Next.js App Router, bắt buộc phải có khi bạn muốn sử dụng Static Site Generation (SSG) cho các route động. Nó được chạy tại thời điểm build và trả về một mảng các object, mỗi object chứa các parameter cần thiết để Next.js biết cần tạo ra phiên bản tĩnh của trang cho những slug nào. Chúng ta dùng hàm getSortedPostsData() để lấy danh sách tất cả các bài viết hiện có, rồi map chúng thành format mà generateStaticParams yêu cầu.
  2. Component PostPage: Đây là Server Component bất đồng bộ. Nó nhận params từ Next.js, trong đó có chứa slug từ URL.
  3. Fetch Data: Component gọi hàm getPostData(slug) (đã viết ở src/lib/posts.ts) để lấy dữ liệu chi tiết của bài viết tương ứng. Vì đây là Server Component và hàm getPostData là async, chúng ta dùng await.
  4. Render: Component hiển thị tiêu đề và metadata từ postData.metadata. Phần nội dung HTML (postData.contentHtml) được render bằng dangerouslySetInnerHTML. Lưu ý rằng dangerouslySetInnerHTML cần được sử dụng cẩn thận và chỉ với nội dung đáng tin cậy (ở đây là nội dung file Markdown do bạn kiểm soát).

Khi bạn chạy npm run build, Next.js sẽ:

  • Thực thi generateStaticParams để tìm tất cả các slug.
  • Đối với mỗi slug, Next.js sẽ gọi component PostPage, lấy dữ liệu bằng getPostData và pre-render thành một file HTML tĩnh tương ứng (.next/server/app/posts/[slug]/page.html).

Điều này mang lại hiệu suất tối đa vì người dùng nhận được HTML đã sẵn sàng mà không cần chờ dữ liệu fetch ở client hay server sau request ban đầu.

Xử lý Markdown và Frontmatter: Chi tiết hơn

Các thư viện chính giúp chúng ta làm việc với file Markdown là:

  • gray-matter: Như đã thấy, nó giúp tách phần frontmatter (YAML) và phần nội dung chính (Markdown) của file. Rất đơn giản và hiệu quả.
  • remark: Đây là một bộ xử lý Markdown rất linh hoạt. Nó hoạt động theo cơ chế AST (Abstract Syntax Tree) và cho phép bạn sử dụng các plugin để biến đổi hoặc chuyển đổi cây cú pháp đó.
    • remark-html: Một plugin phổ biến của remark giúp chuyển đổi cây AST của Markdown sang chuỗi HTML tương ứng.
    • Bạn có thể tìm các plugin khác của remark để xử lý ảnh, code syntax highlighting (ví dụ: remark-prism), tạo table of contents, v.v.

Nếu bạn muốn có trải nghiệm viết bài mạnh mẽ hơn bằng cách nhúng các component React trực tiếp vào file Markdown, bạn có thể cân nhắc sử dụng MDX (.mdx). MDX là một định dạng cho phép bạn viết JSX (code React) bên trong file Markdown. Next.js có hỗ trợ tích hợp cho MDX, và bạn có thể sử dụng thư viện như @mdx-js/react hoặc @next/mdx (cấu hình tích hợp sẵn) để render file MDX. Điều này rất hữu ích nếu bạn muốn tạo các bài viết có tính tương tác cao hoặc sử dụng lại các component UI trong nội dung.

Tóm lại Quy trình Xây dựng Blog SSG với Next.js

Với cấu trúc thư mục, các file Markdown và các hàm helper cùng component như đã trình bày, quy trình hoạt động của blog Next.js cơ bản sử dụng SSG sẽ diễn ra như sau:

  1. Tại thời điểm build (npm run build):
    • Next.js quét thư mục src/app/posts/[slug] và nhận ra đây là một route động cần pre-render.
    • Next.js gọi hàm generateStaticParams() trong src/app/posts/[slug]/page.tsx. Hàm này đọc tất cả các file Markdown trong thư mục posts và trả về danh sách các slug (gioi-thieu-nextjs, xay-dung-layout, ...).
    • Đối với mỗi slug trong danh sách, Next.js gọi component PostPage (với params.slug tương ứng).
    • Component PostPage gọi getPostData(slug), hàm này đọc file Markdown tương ứng, phân tích frontmatter và chuyển đổi nội dung Markdown sang HTML.
    • Next.js render component PostPage với dữ liệu đã lấy và tạo ra một file HTML tĩnh cho mỗi bài viết (ví dụ: /.next/server/app/posts/gioi-thieu-nextjs/page.html).
    • Trang chủ (src/app/page.tsx) cũng được render tĩnh, gọi getSortedPostsData để lấy danh sách bài viết và nhúng nó vào HTML.
  2. Khi người dùng truy cập trang blog:
    • Trình duyệt gửi yêu cầu tới máy chủ.
    • Máy chủ (ví dụ: trên Vercel) trả về file HTML tĩnh đã được tạo sẵn cho trang chủ hoặc trang chi tiết bài viết. Tốc độ tải trang là cực nhanh.
    • Client-side JavaScript của Next.js và React được tải về và "hydrate" (kích hoạt) trang, làm cho các component React trở nên tương tác.

Mô hình SSG này rất hiệu quả cho các trang có nội dung ít thay đổi như blog, trang tài liệu, landing page,...

Những Bước Phát Triển Tiếp Theo

Bài thực hành này đã xây dựng nền tảng cốt lõi cho blog của bạn. Từ đây, bạn có thể phát triển thêm nhiều tính năng để làm cho blog hoàn chỉnhhấp dẫn hơn:

  • Styling: Áp dụng CSS Modules, Tailwind CSS, hoặc các thư viện UI khác để làm cho blog có giao diện đẹp mắt. Bạn sẽ cần style cả danh sách bài viết và nội dung HTML đã chuyển đổi từ Markdown.
  • Phân trang (Pagination): Nếu blog có rất nhiều bài viết, hiển thị tất cả trên trang chủ có thể làm chậm. Hãy triển khai phân trang để chia nhỏ danh sách.
  • Category & Tags: Thêm thông tin category và tags vào frontmatter, sau đó tạo các trang để lọc bài viết theo category hoặc tag.
  • Tìm kiếm: Triển khai chức năng tìm kiếm trên blog (có thể sử dụng các giải pháp client-side như lunr.js cho blog tĩnh, hoặc tích hợp với các dịch vụ search).
  • Bình luận: Tích hợp hệ thống bình luận (ví dụ: Disqus, Commento, Giscus - sử dụng GitHub Issues làm backend, hoặc xây dựng backend riêng với API Routes của Next.js).
  • Syntax Highlighting: Sử dụng các thư viện như Prism.js hoặc highlight.js kết hợp với remark plugin để làm nổi bật các đoạn code trong bài viết.
  • Deploy: Triển khai ứng dụng Next.js của bạn lên các nền tảng hosting tối ưu cho Next.js như Vercel (được phát triển bởi cùng đội ngũ tạo ra Next.js) hoặc Netlify. Quá trình deploy thường rất đơn giản chỉ với vài cú click hoặc lệnh terminal.

Chúc mừng bạn đã hoàn thành bài thực hành cơ bản về xây dựng blog với Next.js! Đây là một kỹ năng quan trọng giúp bạn hiểu rõ hơn về cách Next.js xử lý dữ liệu tĩnh và định tuyến động, mở ra nhiều khả năng xây dựng các ứng dụng web hiệu suất cao khác. Hãy tiếp tục khám phá và xây dựng những tính năng thú vị cho blog của mình nhé!

Comments

There are no comments at the moment.