Next.js and React: how they differ, one app built two ways
React is a UI library. react.dev says plainly that a build tool such as Vite gives you a single-page app, with no answer for routing, data loading or server rendering. You add those yourself, or you choose a framework. Next.js is a framework built on React. This page builds one app two ways on the same React 19.3.0, to show what Next.js adds and what it costs. Every number below comes from one run of the measuring script.
The code on this page is an app that runs: 17/17 checks passed on 2026-11-12. Versions run: next@16.4.4, react@19.3.0, Node.js v26.0.0.
One app, two builds
The app has two pages: the post list at / and an about page at /gioi-thieu. Both pages share two components, DanhSach and ThanhDieuHuong, and both read one file, data/bai-viet.json.
The Next.js build lives in app/: each page.tsx is one URL. The plain React build lives in react/: one entry point, main.tsx, and one function that picks the view from window.location.pathname. The two builds produce different output, so each answers a URL in its own way.
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 before any JavaScript runs: one file per URL, or one file for the whole app
Next.js writes one HTML file per URL. With JavaScript off, the HTML for / carries 3 post titles, because the list is read on the server at build time. /gioi-thieu has its own HTML, and a URL that does not exist answers 404.
The plain React build writes one index.html. With JavaScript off, that HTML carries 0 post titles. / and /gioi-thieu get the same HTML, and the content differs only once JavaScript runs.
A URL that does not exist
Next.js answers 404 for a URL with no matching folder or page in app/.
vite preview answers 200 with the home page for a URL that does not exist: with its default config it serves index.html for any unknown path, the single-page-app fallback. Another static server may behave differently. This page did not measure any other server, and it does not treat that 200 as a property of React.
How the post list reaches the browser
Next.js reads the data on the server at build time. The titles are in the rendered HTML, and once more in the RSC data Next.js inlines into that same page (1 <script> tag). So the data does reach the browser, but inside the HTML: no JavaScript file under .next/static holds a title (the check counts 0 files). Editing the JSON after the build does not change the page: it stays as built.
The plain React build has no list in its HTML. The list sits in the JavaScript the browser downloads (the check finds it in 1 file) and appears only when that file runs.
How much JavaScript the browser downloads
Next.js loads 172 KB of JavaScript (gzip) in 6 files for /. Plain React loads 67 KB in 1 file. Next.js loads more. In return, the HTML already holds the content before any JavaScript runs, as the two sections above show.
This page measures the total, not the causes. It does not say how much of the 172 KB is the router, the React Server Components runtime, or Next.js start-up code.
Routing
Next.js takes URLs from the app/ folder structure. Plain React has nothing like it: react.dev suggests React Router or TanStack Router. This page installs neither. It uses a hand-written pathname comparison, because the app has only two URLs. A larger app should revisit that choice.
How this page was measured
The script copies the app into a work directory, installs the pinned versions, and builds twice: once with Next.js, once with Vite. It runs the Next.js build with next start and the React build with vite preview. It reads the HTML each server returns, loads the JavaScript files that HTML names, and measures their gzip size. The result has 17 checks, and each one can fail.
Both builds use React 19.3.0. The results hold for the versions pinned at the top of the page. When Next.js or React ship a new version, we pin it and measure again; the page does not change by itself.
Checked
- 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
Measuring script (not public yet): bun scripts/guides/verify-vs-react.ts
Related
- Routing in the App Router: folders, route groups and dynamic routes
- SSR, SSG and ISR: choosing how a page renders in Next.js
- Next.js security advisories
- Check the Next.js version of your site
Sources
- 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
Our summary and wording; the code is rewritten from the examples in the Next.js docs. Where they differ, the Next.js docs win.