Next.js và React khác nhau thế nào: cùng một ứng dụng, dựng hai cách
React là thư viện giao diện. react.dev nói rõ rằng một build tool như Vite chỉ cho ra một ứng dụng một trang (SPA), không kèm routing, lấy dữ liệu hay render phía server; những thứ đó bạn tự thêm, hoặc chọn một framework. Next.js là framework dựng trên React. Trang này dựng cùng một ứng dụng hai cách, cùng React 19.3.0, để thấy Next.js thêm gì và tốn gì. Mọi con số dưới đây lấy từ một lần chạy script đo.
Mã trên trang này là một ứng dụng chạy được: 17/17 kiểm tra đạt ngày 12/11/2026. Phiên bản đã chạy thử: next@16.4.4, react@19.3.0, Node.js v26.0.0.
Một ứng dụng, hai bản build
Ứng dụng có hai trang: danh sách bài viết ở /, và một trang giới thiệu ở /gioi-thieu. Hai trang dùng chung hai thành phần, DanhSach và ThanhDieuHuong, và cùng đọc một tệp data/bai-viet.json.
Bản Next.js nằm trong app/: mỗi tệp page.tsx là một URL. Bản React thuần nằm trong react/: một điểm vào, main.tsx, và một hàm chọn giao diện theo window.location.pathname. Hai bản build ra hai thứ khác nhau, nên mỗi bản có cách riêng để trả lời một URL.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { DanhSach, type BaiViet } from "../shared/danh-sach";
// Server Component: đọc file trên máy chủ lúc build. Mã đọc file chạy ở đó; trình duyệt nhận kết quả đã render.
export default function TrangChu() {
const baiViet: BaiViet[] = JSON.parse(readFileSync(join(process.cwd(), "data/bai-viet.json"), "utf8"));
return (
<main>
<h1>Bài viết</h1>
<DanhSach baiViet={baiViet} />
</main>
);
}// Trang thứ hai của cùng ứng dụng. Trong bản Next.js nó là một đường dẫn thật: máy chủ trả về HTML của chính nó.
export default function GioiThieu() {
return (
<main>
<h1>Giới thiệu</h1>
<p>Cùng một ứng dụng, dựng hai cách.</p>
</main>
);
}// Dùng chung cho cả hai bản. Một component React thuần: không hook, không "use client", không đọc dữ liệu —
// nó chỉ nhận danh sách và in ra. Vì vậy nó chạy được cả trên máy chủ (Next) lẫn trong trình duyệt (React thuần).
export type BaiViet = { id: number; tieuDe: string };
export function DanhSach({ baiViet }: { baiViet: BaiViet[] }) {
return (
<ul>
{baiViet.map((b) => (
<li key={b.id}>{b.tieuDe}</li>
))}
</ul>
);
}import { DanhSach } from "../../shared/danh-sach";
import { ThanhDieuHuong } from "../../shared/thanh-dieu-huong";
import { baiViet } from "./du-lieu";
// "Định tuyến" của bản thuần: một phép so sánh trên window.location.pathname. react.dev nói rõ build tool chỉ cho ra một
// ứng dụng một trang, không kèm routing; muốn có thì thêm React Router hoặc TanStack Router, hoặc tự viết như đây.
export function UngDung({ duongDan }: { duongDan: string }) {
return (
<>
<ThanhDieuHuong />
{duongDan === "/gioi-thieu" ? (
<main>
<h1>Giới thiệu</h1>
<p>Cùng một ứng dụng, dựng hai cách.</p>
</main>
) : (
<main>
<h1>Bài viết</h1>
<DanhSach baiViet={baiViet} />
</main>
)}
</>
);
}HTML trước khi chạy JavaScript: một tệp cho mỗi URL, hoặc một tệp cho cả ứng dụng
Next.js ghi một tệp HTML cho mỗi URL. Khi tắt JavaScript, HTML của / vẫn có 3 tiêu đề bài viết, vì danh sách được đọc trên server lúc build. /gioi-thieu có HTML riêng, và một URL không tồn tại trả 404.
Bản React thuần ghi đúng một index.html. Khi tắt JavaScript, HTML đó có 0 tiêu đề bài viết. / và /gioi-thieu nhận cùng một HTML, và nội dung khác nhau chỉ hiện ra sau khi JavaScript chạy.
URL không tồn tại
Next.js trả 404 cho URL không có thư mục hay trang khớp trong app/.
vite preview trả 200 kèm trang chủ cho URL không tồn tại: với cấu hình mặc định, nó trả index.html cho mọi đường dẫn lạ, kiểu fallback của ứng dụng một trang. Một máy chủ tĩnh khác có thể làm khác. Trang này không đo máy chủ nào khác, và không gọi con 200 đó là đặc tính của React.
Danh sách bài đến trình duyệt bằng đường nào
Next.js đọc dữ liệu trên server lúc build. Tiêu đề bài có trong HTML đã render, và có thêm một lần trong dữ liệu RSC mà Next.js nhúng vào chính trang đó (1 thẻ <script>). Vậy dữ liệu vẫn đến trình duyệt, nhưng nằm trong HTML: không tệp JavaScript nào dưới .next/static chứa tiêu đề (kiểm tra đếm được 0 tệp). Sửa tệp JSON sau khi build không đổi trang: trang vẫn là bản đã build.
Bản React thuần không có danh sách trong HTML. Danh sách nằm trong JavaScript mà trình duyệt tải về (kiểm tra tìm thấy nó trong 1 tệp) và chỉ hiện ra khi tệp đó chạy.
Tải về bao nhiêu JavaScript
Next.js tải 172 KB JavaScript (gzip) trong 6 tệp cho /. React thuần tải 67 KB trong 1 tệp. Next.js tải nhiều hơn. Đổi lại, HTML đã có nội dung trước khi JavaScript chạy, như hai mục trên cho thấy.
Trang này đo tổng dung lượng, không đo nguyên nhân. Nó không cho biết phần nào của 172 KB là router, runtime của React Server Components, hay phần khởi động của Next.js.
Routing
Next.js lấy URL từ cấu trúc thư mục app/. Bản React thuần không có gì như thế: react.dev gợi ý dùng React Router hoặc TanStack Router. Trang này không cài router nào. Nó dùng một phép so sánh pathname viết tay, vì ứng dụng chỉ có hai URL. Với ứng dụng lớn hơn, lựa chọn đó nên được xem lại.
Cách đo trang này
Script chép ứng dụng vào một thư mục làm việc, cài phiên bản đã ghim, rồi build hai lần: một lần bằng Next.js, một lần bằng Vite. Bản Next.js được chạy bằng next start, bản React bằng vite preview; script đọc HTML trả về và tải các tệp JavaScript mà HTML đó khai báo, rồi đo kích thước gzip. Kết quả có 17 kiểm tra, và mỗi kiểm tra có thể không đạt.
Hai bản dùng React 19.3.0. Kết quả đúng cho phiên bản ghi ở đầu trang. Khi Next.js hay React ra bản mới, chúng tôi ghim lại và đo lại; không tự đổi trang.
Đã kiểm tra
- next build exits 0:
0 - next build writes an HTML file for each of the two URLs:
2 files - with JavaScript off, the home HTML carries the post titles read on the server:
3 titles - the post titles also reach the browser in the HTML's inline RSC payload:
1 script - the second URL is its own page in the HTML:
own page - a URL that does not exist answers 404:
404 - the post list is not in the JavaScript the browser downloads:
0 files - JavaScript for / in the Next.js build (gzip):
172 KB in 6 files - editing the data file after the build leaves the page as built:
old list - vite build of the plain React app exits 0:
0 - vite build writes one HTML file for the whole app:
1 file - with JavaScript off, the plain React HTML carries no post title:
0 titles - the second URL gets the same HTML as the home page:
the same HTML - vite preview answers an unknown URL 200 with index.html (its SPA fallback):
200, home page - the post list ships inside the JavaScript the browser downloads:
1 file - JavaScript for / in the plain React build (gzip):
67 KB in 1 file - both builds use the same React version:
19.3.0
Script đo (chưa công khai): bun scripts/guides/verify-vs-react.ts
Liên quan
- Routing trong App Router: thư mục, nhóm tuyến và tuyến động
- SSR, SSG, ISR: chọn cách render trong Next.js
- Lỗ hổng bảo mật Next.js
- Kiểm tra phiên bản Next.js của trang bạn
Nguồn
- react.dev — Creating a React app (build tools give a client-only SPA)
- react.dev — Build a React app from scratch (routing and SSR left to you)
- Next.js docs — Project structure
Tóm tắt và diễn giải bằng lời của chúng tôi; mã viết lại từ ví dụ trong tài liệu Next.js. Khi có khác biệt, tài liệu Next.js là chuẩn.