0

Nghiên cứu về NextJS

Tổng quan

  • Next.js là một React framework mạnh mẽ, tích hợp nhiều công nghệ và tính năng tối ưu cho các website chú trọng vào hiệu năng (performance) và SEO. Đây cũng là framework được chính đội ngũ React khuyến nghị sử dụng khi xây dựng ứng dụng web.
  • Tài liệu này nghiên cứu tổng quan các kiến thức cốt lõi của Next.js (tập trung vào phiên bản 15 và 16) và phương pháp tối ưu hóa hiệu năng cho hệ thống.

Những kiến thức cốt lõi

Route

Group Route

  • Cú pháp: app/(group)/about/page.tsx -> Trỏ đến URL /about
  • Đặc điểm: Thư mục nằm trong dấu ngoặc đơn (group) được Next.js bỏ qua, không thêm vào đường dẫn URL.
  • Ứng dụng: Giúp tổ chức và quản lý mã nguồn theo các cụm logic một cách khoa học mà không làm thay đổi cấu trúc URL công khai.

Routing File

Next.js sử dụng các tệp tin có tên cố định để định nghĩa giao diện và hành vi cho từng phân đoạn route:

Tên File Đuôi mở rộng Chức năng
layout .js .jsx .tsx Giao diện chung (Layout)
page .js .jsx .tsx Giao diện chính của trang (Page)
loading .js .jsx .tsx Trạng thái đang tải (Loading UI)
not-found .js .jsx .tsx Giao diện khi không tìm thấy trang (Not found UI)
error .js .jsx .tsx Giao diện xử lý lỗi (Error UI)
global-error .js .jsx .tsx Xử lý lỗi toàn cục (Global error UI)
route .js .ts Điểm cuối API (API endpoint)
template .js .jsx .tsx Layout đặc biệt (Bị buộc re-render khi chuyển trang)
default .js .jsx .tsx Trang dự phòng cho Parallel Route (Fallback page)

Layout

  • Định nghĩa các thành phần giao diện dùng chung cho nhiều trang (ví dụ: Header, Navbar, Footer...).
  • Các component bên trong layout sẽ không bị re-render khi người dùng chuyển đổi giữa các trang con thuộc layout đó, giúp duy trì trạng thái UI.
  • Layout con (Nested Layout) sẽ tự động được lồng vào vị trí của biến children trong layout cha.

Slot (Parallel Routes)

  • Cú pháp: Tạo thư mục có tiền tố @, ví dụ: @slot.
  • Đặc điểm: Tên thư mục @slot không ảnh hưởng đến cấu trúc URL.
  • Ứng dụng: Cho phép truyền trực tiếp các trang độc lập vào tệp layout dưới dạng các thuộc tính (props), hỗ trợ xây dựng cấu trúc định tuyến song song (Parallel Routes).

Ví dụ thực tế

Giả sử bạn muốn xây dựng một trang Dashboard hiển thị song song hai phần độc lập: Thông số (Analytics)Nhân viên (Team).

  • Cấu trúc thư mục:
app/
├── layout.js
├── page.js          (Nội dung truyền vào {children})
├── @analytics/
│   └── page.js      (Nội dung truyền vào {analytics})
└── @team/
    └── page.js      (Nội dung truyền vào {team})

  • Cách triển khai trong Layout (app/layout.js): Layout sẽ nhận các prop tương ứng với tên thư mục có ký tự @.
export default function DashboardLayout({ children, analytics, team }) {
  return (
    <div style={{ display: 'flex', flexDirection: 'column' }}>
      {/* Hiển thị nội dung từ app/page.js */}
      <section>{children}</section>

      <div style={{ display: 'flex', gap: '20px' }}>
        {/* Hiển thị nội dung từ app/@analytics/page.js */}
        <aside style={{ flex: 1, border: '1px solid blue' }}>
          {analytics}
        </aside>

        {/* Hiển thị nội dung từ app/@team/page.js */}
        <aside style={{ flex: 1, border: '1px solid green' }}>
          {team}
        </aside>
      </div>
    </div>
  );
}

Tại sao nên dùng @slot?

  1. Xử lý trạng thái độc lập: Có thể cấu hình tệp loading.js hoặc error.js riêng biệt cho từng Slot. Ví dụ: khi vùng @analytics đang tải dữ liệu phức tạp (hiển thị spinner), vùng @team đã tải xong vẫn có thể tương tác bình thường.
  2. Tách biệt logic: Giúp mã nguồn sạch và tường minh hơn thay vì nhồi nhét quá nhiều component và luồng xử lý vào một file page.js duy nhất.
  3. Hỗ trợ Điều hướng có điều kiện (Conditional Routes): Có thể dựa vào quyền hoặc trạng thái của người dùng để quyết định render Slot:
{isAdmin ? analytics : null}
  1. Lồng Modal (Intercepted Routes): Kết hợp hoàn hảo để tạo các Modal (ví dụ: xem chi tiết ảnh). Khi bấm vào, một Modal hiện lên giữ nguyên ngữ cảnh trang cũ nhưng URL trên trình duyệt vẫn thay đổi tương ứng.

Render

Network Boundary

  • Là ranh giới phân định việc component nào sẽ được render ở phía Server và component nào sẽ render ở phía Client.
  • Hiểu cách khác, đây là công cụ xác định mối quan hệ phụ thuộc và luồng dữ liệu giữa các file trong dự án.
  • Sử dụng chỉ thị 'use client' hoặc 'use server' ở đầu file để thiết lập ranh giới này.

Server Components

React Server Components (RSC) cho phép xây dựng các component UI được render và lưu bộ nhớ đệm (cache) trực tiếp trên môi trường máy chủ. Theo mặc định, Next.js tự động cấu hình mọi component là Server Component nếu không có chỉ định nào khác.

Lợi ích của Server Components
  • Tối ưu hóa Data Fetching: Việc lấy dữ liệu diễn ra trực tiếp trên máy chủ, rút ngắn khoảng cách vật lý đến cơ sở dữ liệu, giúp giảm thời gian phản hồi và tăng tốc độ tải trang.
  • Bảo mật tuyệt đối: Các thông tin và logic nhạy cảm như API key, access token... được thực thi an toàn trên server, loại bỏ nguy cơ rò rỉ xuống mã nguồn phía client.
  • Lưu bộ nhớ đệm hiệu quả: Kết quả render có thể được lưu trữ và tái sử dụng cho nhiều yêu cầu tiếp theo, giảm tải áp lực cho hệ thống.
  • Tăng hiệu năng ứng dụng: Giảm đáng kể dung lượng gói JavaScript gửi xuống client, giúp trình duyệt của người dùng nhẹ hơn và xử lý mượt mà hơn.
  • Tối ưu SEO vượt trội: Giao diện được render hoàn chỉnh thành HTML trước khi truyền xuống client. Nhờ đó, các công cụ tìm kiếm (Search Engines) và các mạng xã hội có thể cào dữ liệu (crawl) nội dung trang một cách chính xác, nâng cao thứ hạng SEO.
  • Hỗ trợ Truyền phát dữ liệu (Streaming): Hỗ trợ chia nhỏ giao diện thành các phần (chunks) và stream dần xuống client. Người dùng có thể nhìn thấy và tương tác trước với các thành phần xong sớm thay vì phải đợi tải toàn bộ trang.
Cách thức hoạt động
  • Quy trình render được chia thành các phần nhỏ (chunks) dựa trên các phân đoạn tuyến đường (individual route segments) và ranh giới Suspense (Suspense Boundaries).

  • Mỗi chunk sẽ trải qua 2 bước render trên Server:

    1. React biên dịch Server Components thành một định dạng dữ liệu đặc biệt gọi là React Server Component Payload (RSC Payload).
    2. Next.js sử dụng RSC Payload và mã JavaScript của các Client Component để kết xuất thành mã HTML hoàn chỉnh trên server.
  • Sau đó, tại Client:

    1. Mã HTML được sử dụng để hiển thị ngay một giao diện tĩnh (non-interactive preview) giúp người dùng thấy nội dung cực nhanh.
    2. RSC Payload được nạp vào để đối chiếu, đồng bộ cây thành phần Server - Client và cập nhật lại cấu trúc DOM.
    3. Trình duyệt tải các gói JavaScript để kích hoạt quá trình Hydration, biến giao diện tĩnh thành giao diện có thể tương tác hoàn chỉnh.

RSC Payload là gì? Là một định dạng biểu diễn dữ liệu dạng chuỗi/nhị phân cho cây React Server Components trên client, bao gồm:

  • Kết quả render của các Server Components.
  • Các vị trí giữ chỗ (Placeholders) cho các Client Components kèm tệp JavaScript liên quan của chúng.
  • Toàn bộ dữ liệu (Props) được truyền từ Server Components sang Client Components.
Các chiến lược Server Rendering

Quy trình cơ bản bao gồm:

  1. Fetch dữ liệu trên Server -> 2. Server render HTML -> 3. Gửi HTML, CSS, JS về Client -> 4. Client hiển thị giao diện tĩnh -> 5. React thực hiện Hydrate để kích hoạt tương tác.

Next.js hỗ trợ 3 chiến lược kết xuất chính trên Server:

1. Static Rendering (Mặc định)

  • Các route được render sẵn tại thời điểm biên dịch ứng dụng (Build Time) hoặc chạy ngầm sau khi dữ liệu được xác thực lại (Revalidation).
  • Sau khi render, các tệp tĩnh này sẽ được phân phối thông qua mạng lưới CDN để đạt tốc độ phản hồi tối đa.
  • Phù hợp cho các trang có nội dung không thay đổi theo từng người dùng cụ thể, ví dụ: trang Blog, trang giới thiệu sản phẩm.

2. Dynamic Rendering

  • Các route được render động trên máy chủ cho từng người dùng tương ứng với mỗi yêu cầu (Request Time).
  • Phù hợp với các trang chứa nội dung mang tính cá nhân hóa cao hoặc phụ thuộc vào thông tin chỉ có tại thời điểm gửi request (ví dụ: Giỏ hàng, thông tin tài khoản cá nhân, kết quả tìm kiếm).
  • Next.js sẽ tự động chuyển sang chế độ Dynamic Rendering nếu phát hiện các hàm động (Dynamic Functions) hoặc yêu cầu dữ liệu không được cache (uncached data request).
  • Các hàm động tiêu biểu:
  • cookies()
  • headers()
  • unstable_noStore()
  • unstable_after()
  • Thuộc tính searchParams của trang.

3. Streaming

  • Giao diện được bẻ nhỏ thành các phần độc lập (chunks) và truyền phát dần về client ngay khi chúng sẵn sàng. Chiến lược này tích hợp mặc định trong App Router của Next.js thông qua việc sử dụng thẻ <Suspense> hoặc file loading.js.
Phân biệt giữa SSR, SSG và ISR
  • SSR (Server-Side Rendering): Render trực tiếp tại runtime cho mỗi request. Có lợi cho SEO và dữ liệu luôn mới, nhưng thời gian phản hồi (TTFB) lâu hơn do phải chờ server xử lý hoàn chỉnh giao diện.
  • SSG (Static Site Generation): Render toàn bộ thành file tĩnh ngay từ lúc build. Tốc độ tải trang cực nhanh, tiết kiệm tài nguyên server nhưng không phù hợp với dữ liệu thay đổi liên tục.
  • ISR (Incremental Static Regeneration): Sự kết hợp hoàn hảo của SSR và SSG. Cho phép cập nhật hoặc tạo mới các trang tĩnh một cách ngầm định (trong nền) sau một khoảng thời gian định sẵn mà không cần phải build lại toàn bộ ứng dụng. Tham khảo chi tiết tại: Next.js Rendering Strategies: SSR, SSG, and ISR Compared

Client Components

Client Components mang lại khả năng xây dựng các giao diện tương tác cao. Mặc dù chạy trên trình duyệt, chúng vẫn được pre-render thành mã HTML tĩnh trên Server trước khi gửi xuống Client nhằm tối ưu tốc độ hiển thị ban đầu.

Lợi ích khi sử dụng
  • Tương tác thời gian thực: Hỗ trợ sử dụng đầy đủ các tính năng truyền thống của React như State (useState()), Effect (useEffect()) và các bộ lắng nghe sự kiện (Event Listeners) để cập nhật UI ngay lập tức theo thao tác người dùng.
  • Truy cập Browser API: Cho phép thao tác trực tiếp với các API đặc thù của trình duyệt như window, document, geolocation, localStorage...
Cách sử dụng
  • Thêm chỉ thị 'use client' ở ngay dòng đầu tiên của file.
  • Khi một file được khai báo 'use client', tất cả các component con hoặc module được import bên trong nó sẽ tự động được gộp chung vào gói mã nguồn phía Client (Client Bundle).
Cơ chế hoạt động

Cơ chế hoạt động thay đổi linh hoạt tùy thuộc vào cách thức người dùng truy cập trang:

Trường hợp 1: Tải trang lần đầu (Full Page Load / Refresh) Next.js sử dụng API của React để render bản xem trước HTML tĩnh cho cả Server và Client Component trên hệ thống máy chủ, theo quy trình:

  1. Tại Server: React render Server Components thành định dạng RSC Payload (trong đó chứa tham chiếu đến Client Components). Tiếp đó, Next.js dựa vào RSC Payload và mã JS của Client Component để kết xuất thành chuỗi HTML hoàn chỉnh của tuyến đường đó.
  2. Tại Client: Trình duyệt dựng ngay chuỗi HTML tĩnh để người dùng thấy giao diện tức thì. Tiếp theo, RSC Payload được nạp vào để đồng bộ hóa cây thành phần (reconcile) và cập nhật DOM. Cuối cùng, mã JavaScript được thực thi để kích hoạt quá trình Hydration làm cho Client Components tương tác được.

Trường hợp 2: Điều hướng nội bộ trang (Subsequent Navigations) Khi người dùng chuyển trang thông qua các liên kết nội bộ (ví dụ: dùng thẻ <Link>), các Client Component sẽ được render trực tiếp hoàn toàn trên trình duyệt (Client) mà không cần yêu cầu Server kết xuất lại file HTML mới.


Mô hình kết hợp Server và Client Components (Composition Patterns)

Bảng hướng dẫn lựa chọn Component phù hợp:

Các mô hình thiết kế dành cho Server Component

1. Chia sẻ dữ liệu giữa các component (Data Sharing) Next.js tích hợp sẵn cơ chế Request Memoization cho hàm fetch. Nếu nhiều component khác nhau cùng gọi một lệnh fetch với cấu hình URL và tham số giống hệt nhau trong cùng một chu kỳ request, Next.js sẽ tự động gộp lại và chỉ gửi duy nhất một yêu cầu mạng. Bạn hoàn toàn có thể yên tâm gọi fetch dữ liệu ở bất kỳ cấp độ nào trong cây component mà không sợ trùng lặp request dư thừa.

2. Cô lập mã nguồn Server (Keeping Server-only Code out of Client) Tránh chạy các đoạn code thiết kế cho server trên môi trường client. Ví dụ, các biến môi trường cấu hình trong file .env mặc định là Private (chỉ đọc được ở Server). Nếu gọi chúng ở Client Component, chúng sẽ trả về một chuỗi rỗng "" và dễ gây ra lỗi logic.

Lưu ý: Để cho phép Client truy cập một biến môi trường, bắt buộc phải thêm tiền tố NEXT_PUBLIC_ vào trước tên biến đó (ví dụ: NEXT_PUBLIC_API_URL).

3. Sử dụng các thư viện và Context Provider của bên thứ ba Nhiều thư viện bên thứ ba (như các thư viện UI Slider, Carousel) sử dụng các React hook (như useState) nhưng chưa cập nhật chỉ thị 'use client'. Nếu import trực tiếp vào Server Component, ứng dụng sẽ báo lỗi.

  • Giải pháp: Bọc thư viện đó vào một Client Component trung gian do bạn tự tạo:
// app/components/carousel-wrapper.js
'use client'
 
import { Carousel } from 'acme-carousel'
 
export default Carousel

// app/page.js (Server Component)
import CarouselWrapper from './components/carousel-wrapper'
 
export default function Page() {
  return (
    <div>
      <p>View pictures</p>
      {/* Hoạt động hoàn hảo vì CarouselWrapper là Client Component */}
      <CarouselWrapper />
    </div>
  )
}

Tương tự đối với các hệ thống Context Provider (như ThemeContext, Redux Provider), bạn không thể gọi trực tiếp trong cấu trúc Server Component. Hãy bọc chúng lại trong một Client Component chuyên biệt:

// app/components/theme-provider.js
'use client'
 
import { createContext } from 'react'
 
export const ThemeContext = createContext({})
 
export default function ThemeProvider({ children }) {
  return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
}

// app/layout.js (Server Component)
import ThemeProvider from './components/theme-provider'
 
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  )
}

Các mô hình thiết kế dành cho Client Component

1. Đẩy các Client Component xuống cấp thấp nhất của cây thành phần Để tối ưu hóa dung lượng Client Bundle, hãy cô lập và tách nhỏ các phần giao diện cần tính tương tác thành các Client Component riêng biệt ở cấp thấp nhất.

  • Ví dụ: Một trang Layout chứa rất nhiều thông tin tĩnh và một thanh tìm kiếm cần sử dụng useState. Thay vì khai báo 'use client' cho toàn bộ Layout, hãy tách riêng một thành phần <SearchBar /> làm Client Component, sau đó import <SearchBar /> vào file Layout (vẫn giữ Layout ở dạng Server Component).

2. Truyền thuộc tính từ Server sang Client (Serialization) Mọi thuộc tính (Props) được truyền từ một Server Component sang một Client Component bắt buộc phải chuyển đổi được thành chuỗi (phải đảm bảo tính Serializable như: chuỗi, số, mảng, object thuần, dữ liệu nguyên thủy...). Các kiểu dữ liệu phức tạp như Function, Class instance... sẽ không thể truyền qua biên giới mạng này.

Kết hợp linh hoạt Server Components và Client Components

Next.js không hỗ trợ hành vi import trực tiếp một Server Component vào bên trong một file Client Component. Tuy nhiên, bạn có thể giải quyết bài toán này bằng cách truyền Server Component vào Client Component dưới dạng thuộc tính children hoặc các slots prop:

// app/components/client-component.js
'use client'
 
import { useState } from 'react'
 
export default function ClientComponent({ children }) {
  const [count, setCount] = useState(0)
  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
      {children} {/* Server Component được render độc lập và hiển thị tại đây */}
    </>
  )
}

// app/page.js (Mặc định là Server Component)
import ClientComponent from './components/client-component'
import ServerComponent from './components/server-component'
 
export default function Page() {
  return (
    <ClientComponent>
      <ServerComponent />
    </ClientComponent>
  )
}

Server Actions

Về bản chất, Server Actions là các hàm không đồng bộ (asynchronous functions) được thực thi trực tiếp trên server, thường dùng để xử lý các tác vụ đột biến dữ liệu (như gửi form, cập nhật DB).

  • Quy tắc thiết lập: Không khai báo trực tiếp Server Action bên trong thân của Client Component. Bạn phải định nghĩa chúng trong Server Component hoặc gom chúng lại vào một file chứa chỉ thị 'use server' riêng biệt, sau đó import vào Client Component để sử dụng.

Quy trình quyết định cơ chế render của Next.js


Tối ưu hóa Hiệu năng (Performance Optimization)

Bộ nhớ đệm (Caching)

Cache là cơ chế then chốt giúp tăng tốc độ phản hồi đáng kể của trang web, giảm thiểu độ trễ và tiết kiệm tài nguyên hệ thống. Tuy nhiên, thách thức lớn nhất là xử lý hiện tượng dữ liệu bị lỗi thời (out-dated). Do đó, việc xác định chính xác thành phần nào nên cache và thời gian lưu trữ trong bao lâu là một mắt xích rất quan trọng trong phát triển phần mềm.

Kỹ thuật Caching trong Next.js

Hệ thống bộ nhớ đệm của Next.js hoạt động phân tầng chặt chẽ từ phía Client đến Server:

1. Phía Client
  • Route Cache (Client-side Cache): Cơ chế tải trước (prefetching) nội dung các trang mà người dùng có khả năng sẽ bấm vào. Thẻ <Link> của Next.js tự động xử lý tính năng này, giúp việc chuyển trang diễn ra tức thì do tài nguyên đã được nạp sẵn về bộ nhớ trình duyệt.
2. Phía Server
  • Request Memoization: Ghi nhớ và gộp các yêu cầu API giống nhau trong cùng một vòng đời render, tránh việc tạo ra các cuộc gọi HTTP lặp lại một cách vô ích lên hệ thống backend.
  • Data Cache: Áp dụng khi gọi API bằng phương thức fetch hoặc truy vấn cơ sở dữ liệu để lưu trữ kết quả của các truy vấn có tần suất lặp lại cao.
  • Component Cache: Tương tự như Data Cache nhưng áp dụng ở cấp độ lưu trữ toàn bộ cấu trúc UI của một thành phần giao diện sau khi được render hoàn chỉnh.

Lưu ý: Bắt đầu từ Next.js 15, cú pháp lưu bộ nhớ đệm đã chuyển sang mô hình mới sử dụng từ khóa chỉ thị 'use cache' phối hợp với hàm cacheLife. Mô hình cũ (legacy model) xem như đã lỗi thời.

  • Ví dụ về Data Cache:
import { cacheLife } from 'next/cache'
 
export async function getUsers() {
  'use cache'
  cacheLife('hours') // Thiết lập lưu cache trong vài giờ
  return db.query('SELECT * FROM users')
}

  • Ví dụ về Component Cache:
import { cacheLife } from 'next/cache'
 
export default async function Page() {
  'use cache'
  cacheLife('hours')
 
  const users = await db.query('SELECT * FROM users')
 
  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  )
}

Cơ chế lưu trữ bộ nhớ đệm (Cache Storage)

  • Mặc định (In-Memory Cache): Next.js lưu cache trực tiếp trên bộ nhớ RAM của máy chủ ứng dụng. Khi có request đầu tiên, dữ liệu được ghi vào RAM và các request kế tiếp sẽ đọc trực tiếp từ đây.
  • Hạn chế: Cơ chế này tiêu tốn dung lượng RAM lớn của máy chủ và bộc lộ nhược điểm nghiêm trọng khi triển khai trên các dịch vụ Serverless Cloud (như Vercel, AWS Lambda...). Bản chất của Serverless là khởi tạo một bản sao (instance) mới khi có request đến và tự động tiêu hủy instance đó khi hết thời gian chờ. Điều này khiến toàn bộ dữ liệu cache lưu trên RAM của instance cũ bị xóa sạch, bắt buộc hệ thống phải tính toán lại từ đầu khi instance mới được dựng lên (Cold Start).
  • Giải pháp - Remote Cache: Chuyển đổi mô hình lưu trữ từ In-Memory sang một hệ thống phân tán bên ngoài (ví dụ: Redis) thông qua tính năng cacheHandlers.
  • Cấu hình mẫu hệ thống Remote Cache trong next.config.ts:
// next.config.ts
import type { NextConfig } from 'next'
 
const nextConfig: NextConfig = {
  cacheHandlers: {
    default: require.resolve('./cache-handlers/default-handler.js'),
    'remote-redis': require.resolve('./cache-handlers/remote-handler.js'),
  },
}
 
export default nextConfig

  • Triển khai mã nguồn xử lý Cache thông qua Redis (cache-handler.js):
// cache-handler.js
const { createClient } = require('redis');

const client = createClient({
  url: process.env.REDIS_URL || 'redis://localhost:6373'
});

client.on('error', (err) => console.error('Redis Client Error', err));
client.connect();

class RedisCacheHandler {
  constructor(options) {
    this.options = options;
  }

  // Lấy dữ liệu từ bộ nhớ đệm Redis
  async get(key) {
    const value = await client.get(key);
    if (!value) return null;
    return JSON.parse(value);
  }

  // Lưu dữ liệu vào bộ nhớ đệm Redis kèm thời gian sống (TTL)
  async set(key, data, ctx) {
    await client.set(key, JSON.stringify(data), {
      EX: ctx.ttl || 86400, // Mặc định hết hạn sau 1 ngày (86400 giây)
    });
  }

  // Làm mới cache dựa theo thẻ định danh (Tag)
  async revalidateTag(tag) {
     // Triển khai logic xóa bộ nhớ đệm theo tag tại đây
  }
}

module.exports = RedisCacheHandler;

Tài liệu tham khảo


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí