Kỹ Thuật Chuyên Sâu • 10 phút đọc • 25/09/2026

Tối Ưu React Server Components Và Next.js App Router Cho Trang Thương Mại Điện Tử Tải Nhanh

Bài này có bản tiếng Anh. Read in English →

Nguyen Hai Trinh

Nguyen Hai Trinh • Senior Software Engineer @ SmileViet

Ứng dụng web trên màn hình
Ảnh: Intel Free Press · CC BY 2.0

Storefront bên mình bán tour với vé. Hai yêu cầu kéo ngược nhau: nội dung phải render sẵn cho search engine, mà giỏ hàng với form đặt chỗ thì phải tương tác được ngay. Chọn một kiểu thì mất kiểu kia.

React Server Components với Next.js App Router là chỗ bọn mình chốt.

Mô hình cũ: tải xong JS mới thấy nội dung

Trước App Router, storefront chạy client-side rendering thuần. Mở trang danh sách tour là tải về một bundle JavaScript khá lớn, chờ component mount, rồi mới fetch dữ liệu và render.

Ba hệ quả, xếp theo mức khó chịu:

  • LCP (Largest Contentful Paint) chậm: trình duyệt tải xong JS, thực thi, gọi API, chờ response, rồi mới vẽ được thứ gì đó. Người dùng ngồi đợi qua mấy bước tuần tự.
  • SEO kém: search engine crawl vào gần như trang trống. Nội dung tour chỉ hiện sau khi JavaScript chạy xong, mà trang này sống bằng organic traffic.
  • Bundle JavaScript phình to: logic fetch, format dữ liệu, cả phần chỉ để render tĩnh — tất cả đóng gói gửi xuống client. Người dùng chẳng bao giờ chạm vào phần đó.

Chia vai: Server Components mặc định, Client khi buộc phải thế

Nguyên tắc mình theo: mặc định mọi thứ là Server Component. Chỉ “nhảy” sang Client Component khi thật sự cần tương tác hoặc state phía trình duyệt — useState, useEffect, event handler, hoặc API chỉ có ở browser.

Trang danh sách tour thì phần lớn giao diện render được ở server: card tour, ảnh, mô tả, giá. Chúng chỉ đọc dữ liệu fetch từ database, không có logic tương tác nào:

// app/tours/page.tsx — Server Component (mặc định, không cần "use client")
import { getTours } from "@/lib/tours";
import { TourFilterBar } from "@/components/tour-filter-bar";
import { TourCard } from "@/components/tour-card";

export default async function ToursPage({
  searchParams,
}: {
  searchParams: { destination?: string };
}) {
  // Fetch trực tiếp trên server, không gửi logic fetch này xuống client
  const tours = await getTours({ destination: searchParams.destination });

  return (
    <div>
      {/* Client Component — cần state cho ô input, dropdown */}
      <TourFilterBar />

      <div className="grid grid-cols-3 gap-4">
        {tours.map((tour) => (
          <TourCard key={tour.id} tour={tour} />
        ))}
      </div>
    </div>
  );
}

TourCard chỉ hiển thị dữ liệu tĩnh. Nó vẫn là Server Component — không một dòng JavaScript nào của nó đi xuống trình duyệt, bên đó chỉ nhận HTML render sẵn. Còn TourFilterBar phải giữ state của ô tìm kiếm với dropdown lọc theo điểm đến, nên buộc phải là Client Component:

// components/tour-filter-bar.tsx
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";

export function TourFilterBar() {
  const [destination, setDestination] = useState("");
  const router = useRouter();

  function handleSearch() {
    router.push(`/tours?destination=${encodeURIComponent(destination)}`);
  }

  return (
    <div className="flex gap-2">
      <input
        value={destination}
        onChange={(e) => setDestination(e.target.value)}
        placeholder="Tìm điểm đến..."
      />
      <button onClick={handleSearch}>Tìm kiếm</button>
    </div>
  );
}

Ranh giới "use client" bọc đúng phần cần tương tác: form đặt chỗ, giỏ hàng, bộ lọc. Phần hiển thị nội dung ở lại server, không gửi JS thừa xuống.

Chia theo luồng request thì ranh giới đó nằm thế này.

flowchart TB
  subgraph LIST["Trang danh sách tour"]
    REQ["Request /tours"] --> PAGE["ToursPage — Server Component"]
    PAGE --> FETCH["getTours() đọc database trên server"]
    FETCH --> CARD["TourCard — Server Component, không gửi JS xuống client"]
    PAGE --> BAR["TourFilterBar — Client Component, useState cho input và dropdown"]
    BAR --> ROUTER["useRouter đổi query destination trên URL"]
  end

  subgraph DETAIL["Trang chi tiết tour"]
    SLUG["Request /tours/[slug]"] --> DETAILPAGE["TourDetailPage — Server Component"]
    DETAILPAGE --> FAST["Tên tour và mô tả render ngay"]
    DETAILPAGE --> SUSP["Suspense bọc LivePriceBlock"]
    SUSP --> SKEL["PriceSkeleton hiện trước"]
    SUSP --> PRICE["LivePriceBlock gọi dịch vụ tỷ giá"]
    PRICE --> FILL["Giá thật điền vào chỗ skeleton"]
  end

Streaming phần chậm bằng Suspense

Trang chi tiết tour gom nhiều nguồn dữ liệu tốc độ khác nhau. Thông tin cơ bản của tour thì nhanh, cache lâu được. Giá cuối cùng thì phải quy đổi theo tỷ giá thực tế tại thời điểm xem — chậm hơn, vì phải gọi dịch vụ tỷ giá bên ngoài gần như real-time.

Chờ hết dữ liệu mới render toàn trang là đợi theo phần chậm nhất. App Router cho stream từng phần UI bằng Suspense: phần nào sẵn sàng thì đi trước, phần còn lại hiện ngay khi có dữ liệu:

// app/tours/[slug]/page.tsx
import { Suspense } from "react";
import { getTourDetail } from "@/lib/tours";
import { LivePriceBlock } from "@/components/live-price-block";
import { PriceSkeleton } from "@/components/price-skeleton";

export default async function TourDetailPage({ params }: { params: { slug: string } }) {
  // Phần nhanh: render ngay, không cần chờ tỷ giá
  const tour = await getTourDetail(params.slug);

  return (
    <article>
      <h1>{tour.name}</h1>
      <p>{tour.description}</p>

      {/* Phần chậm: stream riêng, không chặn phần còn lại của trang */}
      <Suspense fallback={<PriceSkeleton />}>
        <LivePriceBlock tourId={tour.id} basePrice={tour.basePrice} />
      </Suspense>
    </article>
  );
}

LivePriceBlock là một async Server Component riêng, tự gọi dịch vụ tỷ giá bên trong. Nhờ bọc Suspense, tiêu đề với mô tả tour xuống trình duyệt và hiện ra ngay. Phần giá hiện skeleton rồi điền vào lúc tỷ giá về. Trang không đứng hình chờ phần chậm nhất.

Kết quả: bundle nhỏ hơn, LCP nhanh hơn

Áp vào storefront thì mấy chỉ số nhích rõ:

  • JavaScript gửi xuống client giảm đáng kể: phần lớn logic hiển thị nội dung không còn phải đóng gói phía client.
  • LCP (Largest Contentful Paint) nhanh hơn: nội dung chính render sẵn ở server, không chờ JS tải và chạy xong mới fetch dữ liệu.
  • Phần giá real-time mượt hơn: người dùng thấy nội dung chính ngay, thay vì màn hình trắng chờ tất cả cùng lúc.

Vài chỗ dễ sai khi áp dụng

  • Đừng đặt “use client” ở quá cao trong cây component: component cha có “use client” thì cả cây con tĩnh bên dưới cũng chạy ở client, mất hết lợi ích của Server Components. Đẩy ranh giới xuống thấp nhất có thể, chỉ bao đúng phần cần tương tác.
  • Dữ liệu nhạy cảm trong Server Components: code chạy ở server nên rất dễ truy vấn thẳng database hay dùng API key bí mật ngay trong component. Coi chừng serialize rồi gửi dữ liệu đó xuống client qua props.
  • Fallback của Suspense phải tử tế: skeleton loading sơ sài gây nhảy layout (layout shift) khi nội dung thật load xong, kéo điểm Core Web Vitals (CLS) đi ngược lại chỗ mình vừa cải thiện ở LCP.

Server Components không phải công tắc bật lên là nhanh hơn. Việc phải làm là quyết định rõ cái gì cần chạy ở đâu. Với một trang thương mại điện tử cần cả tốc độ lẫn SEO, khoản đó đáng.

Cần người cho dự án contract hoặc freelance?

Mình nhận dự án fullstack — backend Go, frontend React/Next.js, hoặc cả hai đầu. Gửi vài dòng về phạm vi công việc và thời gian, mình phản hồi trong 48 giờ.