Chuyển đến nội dung chính

Next.js: Từ Static Page Đến CMS-Driven Rendering - Day 3

Trong những bài trước, chúng ta đã tìm hiểu các khái niệm cơ bản của React và Next.js. Ở bài này, chúng ta sẽ kết hợp những kiến thức đó để xây dựng một website lịch sử đơn giản, lấy ví dụ về các vị hoàng đế nhà Đường.

Mục tiêu không chỉ là tạo một trang hiển thị thông tin, mà còn hiểu cách phát triển từ một trang HTML tĩnh thành một hệ thống có thể lấy nội dung từ CMS và render bằng các React Components.

Chúng ta sẽ đi qua các bước:

  • Tạo một trang Next.js tĩnh với Bootstrap.
  • Tách giao diện thành các React Components.
  • Sử dụng dynamic route để xử lý nhiều URL.
  • Lấy nội dung từ một CMS API.
  • Tạo ContentRenderer để ánh xạ nội dung CMS sang React Components.
  • Hiểu luồng render từ CMS đến HTML hiển thị trên trình duyệt.

Lưu ý: Các tên như contentHtml, ContentRenderer và CmsPageTemplate trong bài là tên do chúng ta tự đặt để minh họa. Đây không phải API có sẵn của Next.js, React hay Umbraco. JSON của CMS cũng là ví dụ về một API contract do chúng ta thiết kế, không phải cấu trúc mặc định của Umbraco.

1. Bước 1: Tạo một Static Page bằng Next.js

Giả sử chúng ta muốn xây dựng một website lịch sử. Trang đầu tiên giới thiệu Đường Thái Tông Lý Thế Dân.

Ở bước đầu tiên, chưa cần CMS hay API. Chúng ta chỉ cần một trang tĩnh để hiểu cấu trúc giao diện.

Tạo file app/page.tsx:

export default function HomePage() {
  return (
    <main className="container py-5">
      <h1>Đường Thái Tông Lý Thế Dân</h1>

      <p className="lead">
        Một trong những vị hoàng đế nổi bật nhất
        trong lịch sử Trung Quốc.
      </p>

      <section className="mt-4">
        <h2>Thời Trinh Quán</h2>

        <p>
          Thời Trinh Quán thường được nhắc đến như
          một giai đoạn thịnh trị của triều Đường.
        </p>
      </section>
    </main>
  );
}

Ở đây, nội dung được viết trực tiếp trong source code. Nếu muốn thay đổi tiêu đề hoặc nội dung, chúng ta phải sửa code và triển khai lại ứng dụng.

1.1. Sử dụng Bootstrap

Để tập trung vào kiến trúc render, chúng ta có thể dùng Bootstrap thay vì tự viết CSS cho từng thành phần.

Cài Bootstrap:

npm install bootstrap

Import Bootstrap trong app/layout.tsx:

import "bootstrap/dist/css/bootstrap.min.css";
import type { ReactNode } from "react";

export default function RootLayout({
  children,
}: {
  children: ReactNode;
}) {
  return (
    <html lang="vi">
      <body>{children}</body>
    </html>
  );
}

Chúng ta đã có một trang Next.js cơ bản. Tuy nhiên, toàn bộ giao diện vẫn nằm trong một component duy nhất.


2. Bước 2: Tách giao diện thành React Components

Khi website có thêm nhiều trang lịch sử, việc viết toàn bộ HTML trong từng page sẽ khiến code bị trùng lặp.

Ví dụ, trang giới thiệu Đường Cao Tổ Lý Uyên và trang giới thiệu Đường Thái Tông đều có thể cần:

  • Tên nhân vật.
  • Tước hiệu.
  • Thời gian trị vì.
  • Phần giới thiệu.
  • Danh sách thành tựu.

Thay vì sao chép giao diện, chúng ta tạo một component dùng chung.

Tạo file components/history/EmperorProfile.tsx:

interface EmperorProfileProps {
  name: string;
  title: string;
  reign: string;
  description: string;
}

export default function EmperorProfile({
  name,
  title,
  reign,
  description,
}: EmperorProfileProps) {
  return (
    <section className="card my-4">
      <div className="card-body">
        <h2 className="card-title">{name}</h2>

        <p>
          <strong>Tước hiệu:</strong> {title}
        </p>

        <p>
          <strong>Thời gian trị vì:</strong> {reign}
        </p>

        <p>{description}</p>
      </div>
    </section>
  );
}

Sau đó, sử dụng component trong app/page.tsx:

import EmperorProfile from "@/components/history/EmperorProfile";

export default function HomePage() {
  return (
    <main className="container py-5">
      <h1>Lịch sử triều Đường</h1>

      <EmperorProfile
        name="Lý Thế Dân"
        title="Đường Thái Tông"
        reign="626–649"
        description="Ông là một trong những vị hoàng đế có ảnh hưởng lớn trong lịch sử triều Đường."
      />
    </main>
  );
}

Điểm cần nhớ: React Component giúp tái sử dụng giao diện. Dữ liệu được truyền vào qua props, còn component chịu trách nhiệm hiển thị dữ liệu đó.

Nhưng dữ liệu vẫn đang được khai báo trong source code. Nếu website có hàng trăm nhân vật, chúng ta cần một cách quản lý nội dung thuận tiện hơn.


3. Bước 3: Tại sao cần CMS?

Hãy tưởng tượng website lịch sử có những đường dẫn sau:

  • /emperors/tang-taizong
  • /emperors/tang-gaozu
  • /dynasties/tang
  • /events/zhenguan

Nếu mỗi URL đều cần một file page riêng và nội dung được viết trực tiếp trong code, việc quản lý hàng trăm bài viết sẽ ngày càng khó khăn.

Đây là lúc CMS phát huy tác dụng. Người biên tập có thể quản lý tiêu đề, nội dung, hình ảnh và các thông tin liên quan trong CMS. Next.js lấy dữ liệu qua API và hiển thị nội dung đó.

Kiến trúc tổng quát:

Content Editor
      |
      v
     CMS
      |
      | API
      v
   Next.js
      |
      v
React Components
      |
      v
HTML hiển thị trên trình duyệt

Trong kiến trúc này, CMS quản lý nội dung, còn Next.js chịu trách nhiệm xử lý route, lấy dữ liệu và render giao diện.


4. Bước 4: Lấy dữ liệu từ CMS API

Để đơn giản hóa tutorial, chúng ta giả định CMS API trả về dữ liệu theo cấu trúc JSON sau:

{
  "title": "Đường Thái Tông Lý Thế Dân",
  "templateType": "history",
  "contentHtml": "<p>Đường Thái Tông là một vị hoàng đế nổi bật.</p>",
  "componentData": {}
}

Đây là cấu trúc minh họa do chúng ta tự thiết kế. Một CMS cụ thể có thể trả về JSON hoàn toàn khác và cần một lớp chuyển đổi dữ liệu phù hợp.

4.1. Khai báo kiểu dữ liệu

Tạo file types/cms.ts:

export interface CmsPageData {
  title: string;
  templateType: string;
  contentHtml: string;
  componentData: Record<string, unknown>;
}

Trong đó:

  • title: tiêu đề trang.
  • templateType: loại template được sử dụng.
  • contentHtml: chuỗi HTML mô tả nội dung trang.
  • componentData: dữ liệu bổ sung cho những component được nhúng trong nội dung.

4.2. Tạo hàm gọi API

Tạo file lib/cms.ts:

import type { CmsPageData } from "@/app/types/cms";

export async function getCmsPage(
  slug: string
): Promise<CmsPageData | null> {
  const apiBaseUrl = process.env.CMS_API_URL;

  if (!apiBaseUrl) {
    throw new Error("Missing CMS_API_URL");
  }

  const url = new URL(
    `/api/pages/${slug}`,
    apiBaseUrl
  );

  const response = await fetch(url.toString(), {
    next: { revalidate: 60 },
  });

  if (response.status === 404) {
    return null;
  }

  if (!response.ok) {
    throw new Error("Failed to fetch CMS page");
  }

  return response.json() as Promise<CmsPageData>;
}

Giải thích hàm getCmsPage(): Next.js lấy dữ liệu từ CMS như thế nào?

Ở phần trước, chúng ta đã xây dựng hàm getCmsPage() để lấy nội dung trang từ CMS API. Nếu chưa quen với React, bạn không cần lo lắng. Hàm này chủ yếu sử dụng TypeScript, JavaScript bất đồng bộ và HTTP request.

Hãy hiểu đơn giản: getCmsPage() nhận đường dẫn của một trang, gọi API để lấy dữ liệu, sau đó trả dữ liệu về cho Next.js sử dụng để render giao diện.

1. Nhìn tổng thể luồng xử lý

Giả sử người dùng truy cập URL:

http://localhost:3000/emperors/tang-taizong

Next.js cần lấy nội dung tương ứng từ CMS. Luồng xử lý có thể hình dung như sau:

flowchart TD A["Người dùng truy cập URL"] B["Next.js page.tsx"] C["getCmsPage(slug)"] D{"CMS_API_URL đã cấu hình?"} E["Tạo URL API"] F["Gửi request bằng fetch"] G{"HTTP response"} H["Trả về null"] I["Phát sinh lỗi"] J["Đọc JSON"] K["Trả dữ liệu về page.tsx"] A --> B --> C --> D D -- "Chưa" --> I D -- "Rồi" --> E --> F --> G G -- "404" --> H G -- "Lỗi HTTP khác" --> I G -- "Thành công" --> J --> K

Trong sơ đồ, trường hợp thành công thường là HTTP 200. Trong code, điều kiện thực tế là response.ok, tức status nằm trong khoảng 200–299.

2. Giải thích từng phần của hàm

2.1. Import kiểu dữ liệu CmsPageData

import type { CmsPageData } from "@/app/types/cms";

CmsPageData mô tả cấu trúc dữ liệu mà hàm mong đợi nhận được từ CMS API.

Ví dụ, file types/cms.ts có thể định nghĩa:

export interface CmsPageData {
  title: string;
  templateType: string;
  contentHtml: string;
  componentData: Record<string, unknown>;
}
Thuộc tính Ý nghĩa
title Tiêu đề trang.
templateType Loại template của trang.
contentHtml Chuỗi HTML chứa nội dung.
componentData Dữ liệu bổ sung cho các component.

Từ khóa import type dùng để import kiểu dữ liệu cho TypeScript kiểm tra code. Nó không gọi API và cũng không tải nội dung trang.

Lưu ý: Đường dẫn import phải phù hợp với cấu trúc dự án và cấu hình alias @/ trong tsconfig.json.

2.2. Khai báo hàm và kiểu trả về

export async function getCmsPage(
  slug: string
): Promise<CmsPageData | null> {

Hãy đọc từng phần:

  • export: cho phép file khác import và sử dụng hàm này.
  • async: khai báo hàm bất đồng bộ, có thể sử dụng await.
  • slug: string: hàm nhận một chuỗi làm đầu vào, ví dụ "emperors/tang-taizong".
  • Promise<CmsPageData | null>: khi hoàn thành, hàm có thể trả về dữ liệu trang hoặc null.

Ví dụ gọi hàm:

const page = await getCmsPage(
  "emperors/tang-taizong"
);

await có nghĩa là chờ kết quả của thao tác bất đồng bộ trước khi tiếp tục sử dụng dữ liệu.

Vì sao kết quả có thể là null? Vì CMS có thể không tìm thấy trang tương ứng với đường dẫn được yêu cầu.

2.3. Đọc địa chỉ CMS API từ biến môi trường

const apiBaseUrl = process.env.CMS_API_URL;

Trong file .env.local, chúng ta có thể cấu hình:

CMS_API_URL=https://localhost:5001

process.env.CMS_API_URL đọc giá trị cấu hình từ biến môi trường của tiến trình Node.js.

Việc tách URL khỏi source code giúp chúng ta dễ thay đổi địa chỉ backend giữa môi trường local, staging và production.

Trong ví dụ này, hàm được gọi từ Server Component của Next.js nên có thể sử dụng biến môi trường không có tiền tố NEXT_PUBLIC_. Không nên đưa URL hoặc thông tin bí mật dành riêng cho server vào biến môi trường công khai.

2.4. Kiểm tra cấu hình có tồn tại hay không

if (!apiBaseUrl) {
  throw new Error("Missing CMS_API_URL");
}

Nếu biến môi trường chưa được cấu hình, hàm sẽ phát sinh lỗi và dừng xử lý.

Điều này giúp chúng ta phát hiện lỗi cấu hình sớm thay vì tiếp tục tạo URL không hợp lệ.

Nếu vừa sửa file .env.local, hãy khởi động lại server Next.js để nạp lại cấu hình.

2.5. Tạo URL API từ slug

const url = new URL(
  `/api/pages/${slug}`,
  apiBaseUrl
);

Giả sử:

apiBaseUrl = https://localhost:5001
slug       = emperors/tang-taizong

URL được tạo ra sẽ là:

https://localhost:5001/api/pages/emperors/tang-taizong

Trong đó:

  • https://localhost:5001 là địa chỉ backend.
  • /api/pages/ là endpoint do chúng ta thiết kế trong ví dụ.
  • emperors/tang-taizong là đường dẫn trang cần tìm.

Cần phân biệt URL người dùng truy cập và URL Next.js dùng để gọi API:

URL Mục đích
localhost:3000/emperors/tang-taizong URL của website Next.js.
localhost:5001/api/pages/emperors/tang-taizong URL backend dùng để lấy dữ liệu.

Hai URL này có thể thuộc hai server khác nhau.

2.6. Gọi API bằng fetch

const response = await fetch(url.toString(), {
  next: { revalidate: 60 },
});

fetch() gửi HTTP request đến API và nhận về một HTTP response.

Điểm cần chú ý là biến response chưa phải trực tiếp là object dữ liệu trang. Nó chứa thông tin phản hồi HTTP, bao gồm status và body.

Ví dụ, backend có thể trả về JSON:

{
  "title": "Đường Thái Tông Lý Thế Dân",
  "templateType": "history",
  "contentHtml": "<p>Giới thiệu về Lý Thế Dân</p>",
  "componentData": {}
}

Tùy chọn revalidate:

next: { revalidate: 60 }

Trong Next.js, tùy chọn này đặt thời gian revalidation là 60 giây cho dữ liệu fetch có thể cache. Nó cho phép Next.js tái sử dụng dữ liệu cache và làm mới dữ liệu theo cơ chế revalidation.

Điều này không có nghĩa API chắc chắn chỉ được gọi một lần mỗi 60 giây, hoặc dữ liệu tự động cập nhật chính xác tại giây thứ 60. Hành vi thực tế phụ thuộc vào cache và quá trình revalidation của Next.js.

Nếu dữ liệu cần luôn mới hoặc phụ thuộc vào người dùng đang đăng nhập, bạn cần cân nhắc chiến lược cache phù hợp.

2.7. Xử lý trường hợp không tìm thấy trang

if (response.status === 404) {
  return null;
}

HTTP 404 có nghĩa là tài nguyên được yêu cầu không tồn tại.

Ví dụ, người dùng truy cập:

/emperors/unknown-emperor

Nếu CMS không có trang tương ứng, API có thể trả về status 404. Hàm lúc này trả về null thay vì coi đây là một lỗi hệ thống bất ngờ.

Phần gọi hàm có thể xử lý kết quả như sau:

const page = await getCmsPage(path);

if (!page) {
  notFound();
}

notFound() là hàm của Next.js để hiển thị giao diện Not Found.

2.8. Xử lý các lỗi HTTP khác

if (!response.ok) {
  throw new Error("Failed to fetch CMS page");
}

response.ok là true khi HTTP status nằm trong khoảng 200–299. Nếu status nằm ngoài khoảng này, giá trị là false.

Status Ý nghĩa
200 Request thành công.
404 Không tìm thấy trang; code đã xử lý ở bước trước.
401 Có thể thiếu thông tin xác thực.
500 Backend gặp lỗi.

fetch() thông thường không tự phát sinh exception chỉ vì server trả về 404 hoặc 500. Vì vậy, chúng ta phải kiểm tra status hoặc thuộc tính response.ok.

Trong ứng dụng thực tế, có thể bổ sung log, timeout và xử lý lỗi mạng. Nếu CMS yêu cầu authentication, request cũng cần gửi thông tin xác thực phù hợp.

2.9. Đọc JSON và trả dữ liệu về

return response.json() as Promise<CmsPageData>;

response.json() đọc body của HTTP response và chuyển JSON thành dữ liệu JavaScript.

Phần as Promise<CmsPageData> là TypeScript type assertion. Nó thông báo với TypeScript rằng chúng ta kỳ vọng dữ liệu có kiểu CmsPageData.

Tuy nhiên, type assertion không tự kiểm tra JSON có đúng cấu trúc hay không. Nếu backend trả về dữ liệu thiếu thuộc tính hoặc sai kiểu, TypeScript không tự phát hiện lỗi đó ở runtime. Khi cần bảo đảm dữ liệu đầu vào, hãy dùng runtime validation.


3. Tổng hợp luồng request bằng Mermaid

Sơ đồ dưới đây cho thấy cách các phần phối hợp với nhau khi người dùng mở một trang:

sequenceDiagram participant B as Browser participant N as Next.js page.tsx participant G as getCmsPage() participant C as CMS API B->>N: GET /emperors/tang-taizong N->>G: Gọi hàm với slug G->>G: Đọc CMS_API_URL G->>G: Tạo URL API G->>C: HTTP GET request C-->>G: HTTP response G->>G: Kiểm tra status G->>G: Đọc JSON nếu thành công G-->>N: CmsPageData hoặc null N->>N: Render CmsPageTemplate N-->>B: Trả HTML phản hồi

Nếu CMS tìm thấy trang, hàm trả dữ liệu về cho page.tsx. Next.js truyền dữ liệu đó vào CmsPageTemplate để tạo giao diện.

Nếu CMS trả về 404, hàm trả về null, và page.tsx có thể gọi notFound(). Nếu xảy ra lỗi HTTP khác, hàm phát sinh exception để Next.js xử lý theo cơ chế error handling của ứng dụng.


4. Vì sao không thấy request CMS API trong tab Network?

Trong ví dụ này, getCmsPage() được gọi từ Server Component. Vì vậy, request đến CMS API thường được thực hiện ở server Next.js, không phải từ trình duyệt của người dùng.

Do đó, tab Network trong Chrome DevTools có thể chỉ hiển thị request đến website Next.js mà không hiển thị request nội bộ từ Next.js đến CMS.

Để debug, bạn có thể:

  • Đặt breakpoint trong getCmsPage() hoặc page.tsx.
  • Kiểm tra log phía server Next.js.
  • Gọi trực tiếp CMS API bằng Swagger, Postman hoặc công cụ phù hợp.
  • Kiểm tra URL API, status code và JSON response.

5. Ghi nhớ

Hãy xem getCmsPage() như một cầu nối giữa Next.js và CMS:

  1. Nhận slug của trang.
  2. Đọc URL backend từ biến môi trường.
  3. Tạo URL API.
  4. Gửi HTTP request bằng fetch().
  5. Xử lý trường hợp không tìm thấy trang hoặc gặp lỗi.
  6. Đọc JSON và trả dữ liệu về cho Next.js.

Bạn chưa cần thành thạo React để hiểu hàm này. Phần lớn logic liên quan đến TypeScript, HTTP và JavaScript bất đồng bộ. Khi hiểu cách dữ liệu được lấy về, bạn sẽ dễ tiếp tục tìm hiểu cách các React Components sử dụng dữ liệu đó để render giao diện.

Environment local configuration

Mặc định khi khởi tạo dự án Next.js sẽ không tự sinh ra .env.local vì đây là file chứa các biến môi trường nhạy cảm/cấu hình riêng của máy local và thường đã được khai báo sẵn trong .gitignore (để tránh push nhầm lên git).

Tạo file .env.local:

CMS_API_URL=https://localhost:5001

Hãy thay URL và endpoint bằng API thực tế của bạn. Ví dụ này sử dụng fetch phía server, phù hợp với Server Component của Next.js App Router.

Tùy chọn revalidate: 60 cho phép Next.js tái sử dụng dữ liệu đã cache và làm mới theo cơ chế revalidation. Nếu cần dữ liệu mới ngay lập tức hoặc dữ liệu riêng tư theo từng người dùng, cần thiết kế chiến lược cache khác.


5. Bước 5: Sử dụng Dynamic Route

Thay vì tạo một page cho từng hoàng đế, chúng ta sử dụng một dynamic route để xử lý nhiều đường dẫn.

Với Next.js App Router, có thể tạo file:

app/
└── [[...slug]]/
    └── page.tsx

Đây là optional catch-all route. Nó có thể xử lý cả đường dẫn gốc và các đường dẫn có nhiều segment như /emperors/tang-taizong.

Tạo file app/[[...slug]]/page.tsx:

import { notFound } from "next/navigation";
import { getCmsPage } from "@/lib/cms";
import CmsPageTemplate from "@/components/cms/CmsPageTemplate";

interface PageProps {
  params: Promise<{
    slug?: string[];
  }>;
}

export default async function Page({
  params,
}: PageProps) {
  const { slug = [] } = await params;
  const path = slug.join("/");

  const page = await getCmsPage(path);

  if (!page) {
    notFound();
  }

  return <CmsPageTemplate page={page} />;
}

Lưu ý phiên bản: Ví dụ sử dụng cách khai báo params dạng Promise của Next.js 15+. Với Next.js 14, params thường được khai báo đồng bộ. Hãy sử dụng kiểu phù hợp với phiên bản dự án của bạn.

Luồng xử lý lúc này là:

  1. Người dùng truy cập một URL.
  2. Next.js lấy các segment của URL từ params.
  3. Ứng dụng chuyển chúng thành một đường dẫn để tra cứu CMS.
  4. CMS API trả về dữ liệu của trang.
  5. CmsPageTemplate render giao diện.

Điểm quan trọng là dynamic route chỉ giúp xử lý đường dẫn. Nó không tự động biết trang cần hiển thị nội dung gì. Điều đó phụ thuộc vào dữ liệu mà API trả về.


6. Bước 6: Tạo CmsPageTemplate

Chúng ta tạo một component phụ trách bố cục chung cho các trang lấy từ CMS.

Tạo file components/cms/CmsPageTemplate.tsx:

import ContentRenderer from "@/components/cms/ContentRenderer";
import type { CmsPageData } from "@/types/cms";

interface CmsPageTemplateProps {
  page: CmsPageData;
}

export default function CmsPageTemplate({
  page,
}: CmsPageTemplateProps) {
  return (
    <main className="container py-4">
      <header className="mb-4">
        <h1>{page.title}</h1>
      </header>

      <ContentRenderer
        contentHtml={page.contentHtml}
        componentData={page.componentData}
      />
    </main>
  );
}

CmsPageTemplate không cần biết chi tiết từng đoạn HTML bên trong nội dung. Nó chỉ hiển thị tiêu đề và giao phần nội dung cho ContentRenderer.

Đây là cách tách trách nhiệm:

  • page.tsx: xử lý URL và lấy dữ liệu.
  • CmsPageTemplate: tạo bố cục chung.
  • ContentRenderer: chuyển nội dung CMS thành các React elements.
  • EmperorProfile: hiển thị thông tin một vị hoàng đế.

7. Bước 7: Tại sao cần ContentRenderer?

Giả sử CMS trả về nội dung có cả HTML thông thường và một thẻ đại diện cho component:

<p>
  Đường Thái Tông là một trong những vị hoàng đế
  nổi bật của Trung Quốc.
</p>

<emperorprofile
  data-component-id="emperor001"
></emperorprofile>

<p>
  Thời Trinh Quán thường được nhắc đến như một
  giai đoạn thịnh trị.
</p>

Thẻ <emperorprofile> trong ví dụ không phải React Component được trình duyệt hoặc Next.js tự động nhận diện. Chúng ta cần tự viết logic để nhận diện thẻ này và thay nó bằng component React tương ứng.

Để parse HTML, cài thư viện:

npm install html-react-parser

7.1. Xây dựng EmperorProfile

Tạo file components/history/EmperorProfile.tsx:

export interface EmperorProfileData {
  name: string;
  title: string;
  reign: string;
  description: string;
  achievements: string[];
}

interface EmperorProfileProps {
  data: EmperorProfileData;
}

export default function EmperorProfile({
  data,
}: EmperorProfileProps) {
  return (
    <section className="card my-4">
      <div className="card-body">
        <h2 className="card-title">
          {data.name}
        </h2>

        <p>
          <strong>Tước hiệu:</strong>
          {" "}{data.title}
        </p>

        <p>
          <strong>Thời gian trị vì:</strong>
          {" "}{data.reign}
        </p>

        <p>{data.description}</p>

        <h3>Thành tựu tiêu biểu</h3>

        <ul>
          {data.achievements.map((achievement) => (
            <li key={achievement}>
              {achievement}
            </li>
          ))}
        </ul>
      </div>
    </section>
  );
}

7.2. Xây dựng ContentRenderer

Tạo file components/cms/ContentRenderer.tsx:

import parse, {
  Element,
  type DOMNode,
  domToReact,
  type HTMLReactParserOptions,
} from "html-react-parser";

import EmperorProfile from "../history/EmperorProfile";
import type { EmperorProfileData } from "../history/EmperorProfile";

interface ContentRendererProps {
  contentHtml: string;
  componentData: Record<string, unknown>;
}

export default function ContentRenderer({
  contentHtml,
  componentData,
}: ContentRendererProps) {
  const options: HTMLReactParserOptions = {
    replace(node) {
      if (!(node instanceof Element)) {
        return;
      }

      if (node.name === "emperorprofile") {
        const componentId =
          node.attribs["data-component-id"];

        if (!componentId) {
          return <p>Thiếu component ID.</p>;
        }

        const data = componentData[
          componentId
        ] as EmperorProfileData | undefined;

        if (!data) {
          return (
            <p>
              Không tìm thấy dữ liệu component:
              {" "}{componentId}
            </p>
          );
        }

        return <EmperorProfile data={data} />;
      }

      if ("children" in node) {
        return domToReact(
          node.children as DOMNode[],
          options
        );
      }
    },
  };

  return <>{parse(contentHtml, options)}</>;
}

Ở đây, chúng ta có ba bước:

  1. Parse chuỗi HTML thành các node.
  2. Nếu node là emperorprofile, lấy data-component-id.
  3. Tìm dữ liệu tương ứng trong componentData và render EmperorProfile.

Những thẻ HTML thông thường như <p> tiếp tục được chuyển thành React elements bởi thư viện parser.

Lưu ý kỹ thuật: Đây là ví dụ học tập. Trong ứng dụng thực tế, nên xây dựng registry component có kiểm soát, xác thực dữ liệu trước khi sử dụng và sanitize HTML không đáng tin cậy. Không nên cho phép CMS tùy ý chỉ định module hoặc component để import. Việc parse HTML không tự động bảo vệ ứng dụng khỏi XSS.


8. Bước 8: Cung cấp dữ liệu cho component

Để ContentRenderer hoạt động, API cần trả về cả nội dung HTML và dữ liệu tương ứng với component ID.

Ví dụ JSON:

{
  "title": "Đường Thái Tông Lý Thế Dân",
  "templateType": "history",
  "contentHtml": "<p>Đường Thái Tông là một vị hoàng đế nổi bật.</p><emperorprofile data-component-id=\"emperor001\"></emperorprofile><p>Thời Trinh Quán là một giai đoạn đáng chú ý.</p>",
  "componentData": {
    "emperor001": {
      "name": "Lý Thế Dân",
      "title": "Đường Thái Tông",
      "reign": "626–649",
      "description": "Ông củng cố triều đại nhà Đường và mở rộng ảnh hưởng của đế quốc.",
      "achievements": [
        "Củng cố bộ máy chính quyền",
        "Phát triển chính sách tuyển chọn quan lại",
        "Đưa triều Đường bước vào thời kỳ thịnh trị"
      ]
    }
  }
}

Trong ví dụ này, emperor001 đóng vai trò khóa liên kết giữa node HTML và dữ liệu của component.

Có thể hình dung như sau:

contentHtml
    |
    v
<emperorprofile data-component-id="emperor001">
    |
    v
componentData["emperor001"]
    |
    v
EmperorProfile
    |
    v
React Elements

Nhờ cơ chế này, CMS có thể quyết định vị trí của component trong nội dung, còn Next.js chịu trách nhiệm ánh xạ node đó sang component đã được đăng ký trong source code.

Đây chỉ là một thiết kế minh họa. Nếu CMS của bạn trả về cấu trúc block JSON thay vì HTML có thẻ tùy chỉnh, bạn có thể xây dựng renderer trực tiếp từ danh sách block mà không cần parse HTML.


9. Tổng kết kiến trúc

flowchart TD
    A["Browser: /emperors/tang-taizong"]
    B["Next.js dynamic route"]
    C["CMS API"]
    D["CmsPageTemplate"]
    E["ContentRenderer"]
    F["HTML thông thường"]
    G["EmperorProfile"]
    H["React Elements"]
    I["HTML gửi về trình duyệt"]

    A --> B
    B --> C
    C --> D
    D --> E
    E --> F
    E --> G
    F --> H
    G --> H
    H --> I

Luồng xử lý có thể tóm tắt như sau:

  1. Người dùng truy cập một URL.
  2. Next.js lấy slug và gọi CMS API.
  3. API trả về dữ liệu trang.
  4. CmsPageTemplate tạo bố cục chung.
  5. ContentRenderer xử lý nội dung HTML và thay thế thẻ đã đăng ký bằng React Components.
  6. React và Next.js render giao diện thành HTML theo cơ chế rendering đang được sử dụng.

Trong kiến trúc này, không nhất thiết mỗi URL phải có một file page.tsx riêng. Một dynamic route có thể xử lý nhiều URL, còn dữ liệu và nội dung được lấy từ CMS.


10. Debug: Nếu nội dung không hiển thị thì kiểm tra gì?

10.1. Kiểm tra route

Đảm bảo URL đang truy cập được xử lý bởi file app/[[...slug]]/page.tsx và slug được chuyển thành đường dẫn đúng.

10.2. Kiểm tra API

Kiểm tra URL API, status code và JSON trả về. Nếu gọi API trong Server Component, request thường được thực hiện ở server, nên không nhất thiết xuất hiện trong tab Network của trình duyệt.

10.3. Kiểm tra component ID

Nếu HTML chứa:

<emperorprofile data-component-id="emperor001"></emperorprofile>

Thì dữ liệu phải tồn tại ở:

componentData["emperor001"]

Nếu hai ID không khớp, renderer sẽ không tìm được dữ liệu để tạo component.

10.4. Kiểm tra tên thẻ

Renderer chỉ ánh xạ thẻ emperorprofile. Nếu CMS trả về tên thẻ khác, ví dụ historyprofile, điều kiện trong ContentRenderer sẽ không khớp.

10.5. Kiểm tra dữ liệu component

TypeScript interface không tự xác thực JSON trong runtime. Nếu API trả về dữ liệu sai cấu trúc, bạn nên kiểm tra dữ liệu bằng validation schema hoặc các hàm kiểm tra kiểu trước khi render.


11. Bài tập thực hành

Sau khi hoàn thành tutorial, hãy thử mở rộng website:

  1. Tạo component Timeline để hiển thị các sự kiện theo thời gian.
  2. Tạo component QuoteBlock để hiển thị trích dẫn lịch sử.
  3. Mở rộng renderer để nhận diện hai loại thẻ mới.
  4. Tạo thêm trang giới thiệu Đường Cao Tổ bằng cùng một dynamic route.
  5. Thử thay đổi nội dung trong JSON API mà không sửa giao diện component.
  6. Thử trả về lỗi 404 khi CMS không tìm thấy nội dung cho một slug.

12. Kết luận

Qua bài này, chúng ta đã phát triển website từ một trang Next.js tĩnh thành một kiến trúc render nội dung lấy từ CMS.

Những khái niệm quan trọng cần ghi nhớ:

  • Static Page: Nội dung được viết trực tiếp trong source code.
  • React Component: Đóng gói giao diện có thể tái sử dụng.
  • Dynamic Route: Cho phép một route xử lý nhiều đường dẫn.
  • CMS API: Cung cấp nội dung để ứng dụng hiển thị.
  • CmsPageTemplate: Tạo bố cục trang.
  • ContentRenderer: Logic tự xây dựng để chuyển cấu trúc nội dung CMS thành React elements.

Điều quan trọng nhất là phân biệt rõ vai trò của CMS và Next.js. CMS quản lý nội dung, còn Next.js sử dụng dữ liệu đó để render giao diện. Việc ánh xạ HTML hoặc block dữ liệu sang React Components là một quyết định kiến trúc do ứng dụng triển khai, không phải tính năng tự động của Next.js.

Đây là nền tảng để tìm hiểu sâu hơn về kiến trúc Headless CMS, cách quản lý template và cách tổ chức component trong các dự án Next.js thực tế.

Nhận xét

Bài đăng phổ biến từ blog này

[ASP.NET MVC] Authentication và Authorize

Một trong những vấn đề bảo mật cơ bản nhất là đảm bảo những người dùng hợp lệ truy cập vào hệ thống. ASP.NET đưa ra 2 khái niệm: Authentication và Authorize Authentication xác nhận bạn là ai. Ví dụ: Bạn có thể đăng nhập vào hệ thống bằng username và password hoặc bằng ssh. Authorization xác nhận những gì bạn có thể làm. Ví dụ: Bạn được phép truy cập vào website, đăng thông tin lên diễn đàn nhưng bạn không được phép truy cập vào trang mod và admin.

Tổng hợp một số kiến thức lập trình về Amibroker

Giới thiệu về Amibroker Amibroker theo developer Tomasz Janeczko được xây dựng dựa trên ngôn ngữ C. Vì vậy bộ code Amibroker Formula Language sử dụng có syntax khá tương đồng với C, ví dụ như câu lệnh #include để import hay cách gói các object, hàm trong các block {} và kết thúc câu lệnh bằng dấu “;”. AFL trong Amibroker là ngôn ngữ xử lý mảng (an array processing language). Nó hoạt động dựa trên các mảng (các dòng/vector) số liệu, khá giống với cách hoạt động của spreadsheet trên excel.

ASP.NET MVC: Cơ bản về Validation

Validation (chứng thực) là một tính năng quan trọng trong ASP.NET MVC và được phát triển trong một thời gian dài. Validation vắng mặt trong phiên bản đầu tiên của asp.net mvc và thật khó để tích hợp 1 framework validation của một bên thứ 3 vì không có khả năng mở rộng. ASP.NET MVC2 đã hỗ trợ framework validation do Microsoft phát triển, tên là Data Annotations. Và trong phiên bản 3, framework validation đã hỗ trợ tốt hơn việc xác thực phía máy khách, và đây là một xu hướng của việc phát triển ứng dụng web ngày nay.