jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Next.js Core Concepts — From Pages Router to App Router (v14 → v15 → v16)

A comprehensive bilingual guide covering rendering, routing, caching in Next.js across versions 14, 15, and 16. Written in English with inline Vietnamese translations for language learners.

Introduction {Giới thiệu}

If you’ve only ever used Next.js with the Pages Router {Nếu bạn chỉ mới dùng Next.js với Pages Router}, this post is for you {bài này dành cho bạn}. The framework has evolved dramatically from version 14 to 16 {Framework đã thay đổi rất nhiều từ phiên bản 14 đến 16}, and the mental model you built around getStaticProps and getServerSideProps no longer applies {và mô hình tư duy bạn xây dựng xung quanh getStaticPropsgetServerSideProps không còn áp dụng được nữa}.

We’ll cover {Chúng ta sẽ đi qua}:

  • Routing — how file-based routing works in the App Router {cách routing dựa trên file hoạt động trong App Router}
  • Rendering — SSG, SSR, ISR, Streaming, CSR, PPR {các chiến lược render}
  • Caching — how it changed from “cache everything” to “cache nothing” to “cache explicitly” {cách caching thay đổi từ “cache mọi thứ” sang “không cache gì” sang “cache có chủ đích”}
  • Version differences — what broke, what improved across v14 → v15 → v16 {những gì thay đổi, cải thiện qua các phiên bản}

This post uses bilingual format {Bài viết sử dụng định dạng song ngữ}: English first, Vietnamese in curly braces {} {tiếng Anh trước, tiếng Việt trong ngoặc nhọn {}}. Read naturally in English, glance at Vietnamese for context {Đọc tự nhiên bằng tiếng Anh, liếc qua tiếng Việt để hiểu ngữ cảnh}.


Pages Router vs App Router {Pages Router và App Router}

The Old World: Pages Router {Thế giới cũ: Pages Router}

In the Pages Router {Trong Pages Router}, every file in pages/ became a route {mọi file trong pages/ trở thành một route}:

pages/
├── index.tsx          → /
├── about.tsx          → /about
├── blog/
│   ├── index.tsx      → /blog
│   └── [slug].tsx     → /blog/:slug
└── api/
    └── hello.ts       → /api/hello

Data fetching was done through special functions {Việc lấy dữ liệu được thực hiện qua các hàm đặc biệt}:

// pages/blog/[slug].tsx — Pages Router
export async function getStaticProps({ params }) {
  const post = await fetchPost(params.slug);
  return { props: { post }, revalidate: 60 };
}

export async function getStaticPaths() {
  const slugs = await getAllSlugs();
  return {
    paths: slugs.map((slug) => ({ params: { slug } })),
    fallback: 'blocking',
  };
}

export default function BlogPost({ post }) {
  return <article>{post.content}</article>;
}

Problems with this model {Vấn đề của mô hình này}:

  • All components are client components by default {Tất cả component mặc định là client component} — they ship JavaScript to the browser {chúng gửi JavaScript đến trình duyệt}
  • Data fetching is page-level only {Lấy dữ liệu chỉ ở cấp page} — you can’t fetch in a nested component without prop drilling or client-side fetching {bạn không thể fetch trong component lồng nhau mà không truyền prop hoặc fetch phía client}
  • Layouts require workarounds {Layout cần giải pháp tạm} — no native nested layouts {không có nested layout native}

The New World: App Router {Thế giới mới: App Router}

Introduced in Next.js 13, stabilized in 14 {Được giới thiệu ở Next.js 13, ổn định ở 14}, the App Router flips everything {App Router đảo ngược mọi thứ}:

app/
├── layout.tsx         → Root layout (wraps everything)
├── page.tsx           → /
├── about/
│   └── page.tsx       → /about
├── blog/
│   ├── layout.tsx     → Blog layout (persists across blog pages)
│   ├── page.tsx       → /blog
│   └── [slug]/
│       └── page.tsx   → /blog/:slug
└── api/
    └── hello/
        └── route.ts   → /api/hello

Key paradigm shifts {Những thay đổi mô hình chính}:

Pages RouterApp Router
All components are Client Components {Tất cả là Client Component}All components are Server Components by default {Mặc định là Server Component}
getStaticProps / getServerSidePropsJust async functions — fetch directly in components {Chỉ cần hàm async — fetch trực tiếp trong component}
No nested layouts {Không có nested layout}Native nested layouts {Nested layout native}
_app.tsx + _document.tsxlayout.tsx at any level {layout.tsx ở bất kỳ cấp nào}
API routes in pages/api/Route Handlers in app/api/.../route.ts
// app/blog/[slug]/page.tsx — App Router
// This is a Server Component by default {Đây là Server Component mặc định}
export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params; // async in v15+
  const post = await fetchPost(slug);

  return <article>{post.content}</article>;
}

Notice {Chú ý}: no getStaticProps, no export default function receiving props from a data function {không cần getStaticProps, không cần export default function nhận props từ hàm data}. The component itself IS the data layer {Component chính nó LÀ tầng data}.


Routing Deep-Dive {Routing chi tiết}

File Conventions {Quy ước file}

The App Router uses special filenames to define UI structure {App Router dùng tên file đặc biệt để định nghĩa cấu trúc UI}:

FilePurpose {Mục đích}
page.tsxThe UI for a route — makes the route publicly accessible {UI cho route — làm route có thể truy cập}
layout.tsxShared UI that wraps children — persists across navigations {UI dùng chung bọc children — tồn tại qua các lần navigate}
loading.tsxLoading UI shown while content streams in {UI loading hiển thị khi content đang stream}
error.tsxError boundary for a route segment {Error boundary cho route segment}
not-found.tsxUI for 404 within a segment {UI cho 404 trong segment}
template.tsxLike layout but re-mounts on navigation {Giống layout nhưng re-mount khi navigate}
default.tsxFallback UI for parallel routes {UI mặc định cho parallel route}
route.tsAPI endpoint (GET, POST, etc.) {API endpoint}

Layouts — The Killer Feature {Layouts — Tính năng đỉnh nhất}

Layouts persist across page navigations {Layout tồn tại xuyên suốt các lần chuyển trang}. They don’t re-render when you navigate between sibling pages {Chúng không re-render khi bạn navigate giữa các trang cùng cấp}:

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex">
      <Sidebar /> {/* Never re-renders on navigation */}
      <main className="flex-1">{children}</main>
    </div>
  );
}

Layouts can be nested {Layout có thể lồng nhau}:

app/
├── layout.tsx              → Root: html, body, fonts, providers
├── (marketing)/
│   ├── layout.tsx          → Marketing: header, footer
│   ├── page.tsx            → /
│   └── pricing/page.tsx    → /pricing
└── (dashboard)/
    ├── layout.tsx          → Dashboard: sidebar, auth check
    ├── page.tsx            → /dashboard (if you set it up)
    └── settings/page.tsx   → /settings

Route Groups {Nhóm route}

Wrap folders in parentheses () to organize without affecting the URL {Bọc folder trong ngoặc tròn () để tổ chức mà không ảnh hưởng URL}:

app/
├── (auth)/
│   ├── login/page.tsx      → /login  (not /auth/login)
│   └── register/page.tsx   → /register
├── (shop)/
│   ├── layout.tsx          → Different layout for shop pages
│   ├── products/page.tsx   → /products
│   └── cart/page.tsx       → /cart

This lets you have multiple root layouts {Điều này cho phép bạn có nhiều root layout} — different headers/footers for different sections of your app {header/footer khác nhau cho các phần khác nhau của app}.

Dynamic Routes {Route động}

app/blog/[slug]/page.tsx         → /blog/hello-world
app/shop/[...slugs]/page.tsx     → /shop/a/b/c (catch-all)
app/docs/[[...slugs]]/page.tsx   → /docs OR /docs/a/b (optional catch-all)
// app/blog/[slug]/page.tsx
export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  // In v14: params was synchronous → params.slug
  // In v15+: params is async → await params, then destructure
  return <h1>{slug}</h1>;
}

Parallel Routes {Route song song}

Render multiple pages in the same layout simultaneously {Render nhiều page trong cùng layout đồng thời}:

app/dashboard/
├── layout.tsx
├── page.tsx
├── @analytics/
│   ├── page.tsx            → Analytics panel
│   └── default.tsx         → Fallback when not matched
├── @team/
│   ├── page.tsx            → Team panel
│   └── default.tsx
// app/dashboard/layout.tsx
export default function Layout({
  children,
  analytics,
  team,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  team: React.ReactNode;
}) {
  return (
    <div>
      {children}
      <div className="grid grid-cols-2">
        {analytics}
        {team}
      </div>
    </div>
  );
}

Use cases {Trường hợp sử dụng}: dashboards, modals that preserve URL state, conditional rendering based on auth {dashboard, modal giữ URL state, render có điều kiện dựa trên auth}.

Intercepting Routes {Route chặn}

Show a route in a different context {Hiển thị route trong ngữ cảnh khác} — like opening a photo in a modal while the URL updates {như mở ảnh trong modal trong khi URL cập nhật}:

app/
├── feed/
│   ├── page.tsx
│   └── (..)photo/[id]/     → Intercepts /photo/[id] from feed
│       └── page.tsx         → Shows in modal
└── photo/[id]/
    └── page.tsx             → Full page (direct navigation or refresh)

Convention {Quy ước}: (.) same level, (..) one level up, (..)(..) two levels up, (...) from root.

Middleware → Proxy (v16) {Middleware → Proxy (v16)}

In v14-15, middleware.ts ran at the edge {Ở v14-15, middleware.ts chạy ở edge}:

// middleware.ts (v14-15)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  if (!request.cookies.get('token')) {
    return NextResponse.redirect(new URL('/login', request.url));
  }
}

export const config = {
  matcher: ['/dashboard/:path*'],
};

In v16, this becomes proxy.ts {Ở v16, đổi thành proxy.ts} — running on Node.js runtime by default {chạy trên Node.js runtime mặc định}:

// proxy.ts (v16)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function proxy(request: NextRequest) {
  if (!request.cookies.get('token')) {
    return NextResponse.redirect(new URL('/login', request.url));
  }
}

export const config = {
  matcher: ['/dashboard/:path*'],
};

Why the rename {Tại sao đổi tên}? “Middleware” implied it could do anything {gợi ý nó có thể làm mọi thứ}. In reality, it’s a network proxy {Thực tế, nó là proxy mạng} — it intercepts requests before they reach your app {nó chặn request trước khi chúng đến app}. The new name clarifies its role {Tên mới làm rõ vai trò}. Plus, running on Node.js gives access to fs, crypto, and other Node APIs {Thêm nữa, chạy trên Node.js cho phép truy cập fs, crypto, và các Node API khác}.


Rendering Strategies {Các chiến lược Render}

This is where Next.js gets complex but powerful {Đây là nơi Next.js trở nên phức tạp nhưng mạnh mẽ}. Let’s go through each strategy {Hãy đi qua từng chiến lược}:

Server Components vs Client Components {Server Component và Client Component}

The fundamental building block {Khối xây dựng cơ bản} of the App Router:

// Server Component (default) — NO "use client" directive
export default async function ProductList() {
  const products = await db.query('SELECT * FROM products');
  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}>{p.name} - ${p.price}</li>
      ))}
    </ul>
  );
}
// Client Component — needs interactivity
'use client';

import { useState } from 'react';

export default function AddToCart({ productId }: { productId: string }) {
  const [count, setCount] = useState(0);
  return (
    <button onClick={() => setCount((c) => c + 1)}>
      Add to cart ({count})
    </button>
  );
}

When to use which {Khi nào dùng cái nào}:

Server ComponentClient Component
Fetch data {Lấy dữ liệu}Event handlers (onClick, onChange) {Xử lý sự kiện}
Access backend directly {Truy cập backend trực tiếp}useState, useEffect, hooks
Keep secrets safe {Giữ bí mật an toàn}Browser APIs (localStorage, window)
Reduce client bundle {Giảm bundle phía client}Third-party interactive libraries {Thư viện tương tác bên thứ 3}

SSG — Static Site Generation {Tạo trang tĩnh}

Pages are rendered at build time {Trang được render lúc build}. The HTML is generated once and served from CDN {HTML được tạo một lần và phục vụ từ CDN}.

// app/blog/[slug]/page.tsx — statically generated at build time
export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);
  return <article>{post.content}</article>;
}

When it’s chosen {Khi nào được chọn}: Next.js automatically uses SSG when your page has no dynamic functions (cookies(), headers(), searchParams) and all data is cacheable {Next.js tự động dùng SSG khi page không có hàm động và tất cả data có thể cache}.

Equivalent in Pages Router {Tương đương ở Pages Router}: getStaticProps + getStaticPaths.

SSR — Server-Side Rendering {Render phía server}

Pages are rendered on every request {Trang được render mỗi request}. Fresh data every time, but slower {Dữ liệu mới mỗi lần, nhưng chậm hơn}.

// app/dashboard/page.tsx
import { cookies } from 'next/headers';

export default async function Dashboard() {
  // Using cookies() makes this page dynamic (SSR)
  const token = (await cookies()).get('session');
  const data = await fetchDashboardData(token?.value);

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

You can also force SSR explicitly {Bạn cũng có thể ép SSR rõ ràng}:

// Force dynamic rendering
export const dynamic = 'force-dynamic';

Equivalent in Pages Router {Tương đương ở Pages Router}: getServerSideProps.

ISR — Incremental Static Regeneration {Tái tạo tĩnh tăng dần}

The best of both worlds {Tốt nhất của cả hai}: static speed + background updates {tốc độ tĩnh + cập nhật nền}:

// app/products/page.tsx
// Revalidate every 60 seconds
export const revalidate = 60;

export default async function Products() {
  const products = await fetch('https://api.store.com/products');
  return <ProductGrid products={products} />;
}

How it works {Cách hoạt động}:

  1. First request → serves cached version {Request đầu → phục vụ bản cache}
  2. After 60s, next request still serves cache BUT triggers background regeneration {Sau 60s, request tiếp vẫn phục vụ cache NHƯNG kích hoạt tái tạo nền}
  3. Once regeneration completes, new version replaces old cache {Khi tái tạo xong, bản mới thay thế cache cũ}

On-demand revalidation {Revalidation theo yêu cầu}:

// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';

export async function POST(request: Request) {
  const { path, tag } = await request.json();

  if (path) revalidatePath(path);
  if (tag) revalidateTag(tag);

  return Response.json({ revalidated: true });
}

Equivalent in Pages Router {Tương đương ở Pages Router}: getStaticProps with revalidate option.

Streaming SSR {Render server theo luồng}

Instead of waiting for all data before sending HTML {Thay vì đợi tất cả data trước khi gửi HTML}, stream parts as they become ready {stream từng phần khi chúng sẵn sàng}:

// app/dashboard/page.tsx
import { Suspense } from 'react';

export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <StaticHeader />

      {/* Streams in when data is ready */}
      <Suspense fallback={<Skeleton />}>
        <SlowDataComponent />
      </Suspense>

      <Suspense fallback={<ChartSkeleton />}>
        <AnalyticsChart />
      </Suspense>
    </div>
  );
}

async function SlowDataComponent() {
  // Takes 2 seconds — but doesn't block the whole page
  const data = await fetchSlowAPI();
  return <DataTable data={data} />;
}

The loading.tsx convention is syntactic sugar for Suspense {Quy ước loading.tsx là cú pháp rút gọn cho Suspense}:

// app/dashboard/loading.tsx — Automatically wraps page.tsx in Suspense
export default function Loading() {
  return <DashboardSkeleton />;
}

CSR — Client-Side Rendering {Render phía client}

For highly interactive UIs that don’t need SEO {Cho UI tương tác cao không cần SEO}:

'use client';

import { useEffect, useState } from 'react';

export default function LiveChat() {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    const ws = new WebSocket('wss://chat.example.com');
    ws.onmessage = (e) => {
      setMessages((prev) => [...prev, JSON.parse(e.data)]);
    };
    return () => ws.close();
  }, []);

  return <ChatUI messages={messages} />;
}

PPR — Partial Prerendering {Render trước một phần}

The future of rendering {Tương lai của rendering}. PPR combines static shells with dynamic streaming {PPR kết hợp shell tĩnh với streaming động}:

┌──────────────────────────────────────────────┐
│  Static Shell (served from CDN instantly)     │
│  ┌────────────────────────────────────────┐  │
│  │  Header, Navigation, Layout            │  │
│  │  (pre-rendered at build time)          │  │
│  └────────────────────────────────────────┘  │
│                                              │
│  ┌──────────────┐  ┌─────────────────────┐  │
│  │  🕐 Dynamic   │  │  🕐 Dynamic          │  │
│  │  User Info   │  │  Recommendations    │  │
│  │  (streamed)  │  │  (streamed)         │  │
│  └──────────────┘  └─────────────────────┘  │
└──────────────────────────────────────────────┘
// app/store/page.tsx — PPR in action
import { Suspense } from 'react';
import { cookies } from 'next/headers';

// Static part — rendered at build time
function ProductCatalog() {
  return <StaticProductGrid />;
}

// Dynamic part — streamed at request time
async function PersonalizedRecommendations() {
  const session = (await cookies()).get('session');
  const recs = await getRecommendations(session?.value);
  return <RecommendationCarousel items={recs} />;
}

export default function StorePage() {
  return (
    <div>
      <ProductCatalog />
      <Suspense fallback={<RecSkeleton />}>
        <PersonalizedRecommendations />
      </Suspense>
    </div>
  );
}

PPR across versions {PPR qua các phiên bản}:

  • v14: Experimental flag experimental.ppr = true {Cờ thử nghiệm}
  • v15: Stable, opt-in per route {Ổn định, bật từng route}
  • v16: Default rendering strategy — PPR replaces the SSG/SSR binary choice {Chiến lược render mặc định — PPR thay thế lựa chọn nhị phân SSG/SSR}

Rendering Decision Flowchart {Sơ đồ quyết định Render}

Does your page use dynamic functions?
(cookies, headers, searchParams, uncached fetch)

    YES → Does it need the fastest TTFB?

              ├── YES → PPR (static shell + dynamic holes)

              └── NO → SSR (full dynamic render)

    NO → Does data change periodically?

              ├── YES → ISR (static + background revalidation)

              └── NO → SSG (pure static, rebuild to update)

Caching — The Biggest Evolution {Caching — Sự thay đổi lớn nhất}

Caching is what changed most dramatically across versions {Caching là thứ thay đổi nhiều nhất qua các phiên bản}. Understanding this evolution is critical {Hiểu sự thay đổi này là rất quan trọng}.

v14: Cache Everything by Default {v14: Cache mọi thứ mặc định}

In Next.js 14, the philosophy was aggressive caching {Ở Next.js 14, triết lý là cache tích cực}:

// Next.js 14 — This fetch is CACHED by default!
const data = await fetch('https://api.example.com/posts');
// Equivalent to: fetch(..., { cache: 'force-cache' })

// To opt OUT of caching, you had to be explicit:
const freshData = await fetch('https://api.example.com/posts', {
  cache: 'no-store',
});

Four caching layers in v14 {Bốn tầng cache ở v14}:

LayerWhat it caches {Cache cái gì}Duration {Thời gian}
Request MemoizationDuplicate fetch() calls in same render {Các lệnh fetch() trùng trong cùng render}Per-request {Mỗi request}
Data Cachefetch() responses on server {Response fetch() trên server}Persistent (until revalidated) {Vĩnh viễn (đến khi revalidate)}
Full Route CacheEntire rendered HTML + RSC payload {Toàn bộ HTML + RSC payload đã render}Persistent (static routes)
Router CacheRSC payload on client for navigation {RSC payload trên client để navigate}Session-based {Theo session}

The problem {Vấn đề}: developers were confused {dev bị bối rối}. Data was stale and they didn’t know why {Data cũ mà không biết tại sao}. The implicit caching made debugging a nightmare {Caching ẩn khiến debug như ác mộng}.

v15: Cache Nothing by Default {v15: Không cache gì mặc định}

Next.js 15 reversed the default {Next.js 15 đảo ngược mặc định}:

// Next.js 15 — This fetch is NOT cached by default!
const data = await fetch('https://api.example.com/posts');
// Equivalent to: fetch(..., { cache: 'no-store' })

// To opt IN to caching:
const cachedData = await fetch('https://api.example.com/posts', {
  cache: 'force-cache',
});

// Or with revalidation:
const isrData = await fetch('https://api.example.com/posts', {
  next: { revalidate: 3600 },
});

This was a breaking change {Đây là breaking change}. Apps that relied on implicit caching suddenly became slower {App dựa vào cache ẩn bỗng chậm đi} because every request hit the origin server {vì mọi request đều đến server gốc}.

v16: Explicit Caching with use cache {v16: Cache có chủ đích với use cache}

Next.js 16 introduces Cache Components {Next.js 16 giới thiệu Cache Component} — a completely new mental model {một mô hình tư duy hoàn toàn mới}:

// next.config.ts — Enable Cache Components
import type { NextConfig } from 'next';

const config: NextConfig = {
  cacheComponents: true,
};

export default config;

File-level caching {Cache cấp file}

// app/products/page.tsx
'use cache';

// The ENTIRE page output is cached
export default async function ProductsPage() {
  const products = await db.query('SELECT * FROM products');
  return <ProductGrid products={products} />;
}

Function-level caching {Cache cấp hàm}

// lib/data.ts
import { cacheLife, cacheTag } from 'next/cache';

export async function getProducts() {
  'use cache';
  cacheLife('hours');
  cacheTag('products');

  return await db.query('SELECT * FROM products');
}

export async function getUser(id: string) {
  'use cache';
  cacheLife('minutes');
  cacheTag(`user-${id}`);

  return await db.query('SELECT * FROM users WHERE id = $1', [id]);
}

Component-level caching {Cache cấp component}

// components/ProductCard.tsx
async function ProductCard({ id }: { id: string }) {
  'use cache';
  cacheTag(`product-${id}`);

  const product = await getProduct(id);
  return (
    <div>
      <h3>{product.name}</h3>
      <p>${product.price}</p>
    </div>
  );
}

Cache Profiles {Hồ sơ cache}

import { cacheLife } from 'next/cache';

// Built-in profiles:
cacheLife('seconds');  // short TTL
cacheLife('minutes');  // medium TTL
cacheLife('hours');    // long TTL
cacheLife('days');     // very long TTL
cacheLife('weeks');    // rarely changes
cacheLife('max');      // cache as long as possible

// Custom profile:
cacheLife({ stale: 300, revalidate: 60, expire: 3600 });

Invalidation in v16 {Invalidation ở v16}

import { revalidateTag } from 'next/cache';

// v16: revalidateTag now uses SWR pattern
// Serves stale while revalidating in background
await revalidateTag('products');

// New: updateTag() for immediate invalidation
import { updateTag } from 'next/cache';
await updateTag('products'); // Immediately purges

Caching Comparison Table {Bảng so sánh Caching}

Behavior {Hành vi}v14v15v16
fetch() defaultCached {Có cache}Not cached {Không cache}Not cached (use 'use cache' to opt in)
Opt-in mechanismcache: 'no-store' to disablecache: 'force-cache' to enable'use cache' directive
Granularity {Độ chi tiết}Per-fetchPer-fetchFile / Function / Component
Cache profilesN/AN/AcacheLife()
Tag-based invalidationrevalidateTag()revalidateTag()revalidateTag() (SWR) + updateTag()
Mental model {Mô hình tư duy}“Everything cached unless you say no” {Mọi thứ cache trừ khi nói không}“Nothing cached unless you say yes” {Không gì cache trừ khi nói có}“Mark what to cache, control how long” {Đánh dấu cái cần cache, kiểm soát bao lâu}

Version Comparison {So sánh phiên bản}

The Big Picture {Bức tranh toàn cảnh}

Feature {Tính năng}Next.js 14Next.js 15Next.js 16
React versionReact 18React 19React 19.2
BundlerWebpack (Turbopack opt-in)Webpack (Turbopack stable for dev)Turbopack default (dev + prod)
Caching defaultAggressive (cached) {Tích cực}Opt-in (not cached) {Chủ động}Explicit (use cache) {Rõ ràng}
Request APIsSynchronousAsync (with deprecation warnings) {Bất đồng bộ (có cảnh báo)}Strictly async {Bắt buộc bất đồng bộ}
Middlewaremiddleware.ts (Edge)middleware.ts (Edge)proxy.ts (Node.js)
PPRExperimentalStable (opt-in)Default strategy {Mặc định}
Server ActionsStableEnhanced with validationMature {Trưởng thành}
React CompilerN/AExperimentalStable (opt-in)
Node.js minimum18.1718.1820.9
next lintAvailableAvailableRemoved {Đã xoá}
AMP supportDeprecated {Không khuyến khích}DeprecatedRemoved {Đã xoá}
Build speedBaseline2-3x faster (Turbopack dev)5-10x faster (Turbopack prod)

Breaking Changes Per Version {Breaking Change mỗi phiên bản}

v14 → v15 Breaking Changes {Thay đổi gây lỗi từ v14 sang v15}

  1. Caching reversed {Caching đảo ngược}: fetch() no longer cached by default {fetch() không còn cache mặc định}
  2. Async Request APIs {API Request bất đồng bộ}: cookies(), headers(), params, searchParams became Promises {trở thành Promise}
  3. React 19 {React 19}: new hooks, changed behavior for ref forwarding
// v14 → v15 migration for cookies

// Before (v14) — synchronous
import { cookies } from 'next/headers';
const token = cookies().get('session');

// After (v15) — asynchronous
import { cookies } from 'next/headers';
const token = (await cookies()).get('session');
// v14 → v15 migration for params

// Before (v14)
export default function Page({ params }: { params: { slug: string } }) {
  return <h1>{params.slug}</h1>;
}

// After (v15)
export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  return <h1>{slug}</h1>;
}

v15 → v16 Breaking Changes {Thay đổi gây lỗi từ v15 sang v16}

  1. Turbopack default {Turbopack mặc định}: custom Webpack configs need --webpack flag {config Webpack tuỳ chỉnh cần cờ --webpack}
  2. middleware.tsproxy.ts {Đổi tên}: different filename AND function name {khác tên file VÀ tên hàm}
  3. next lint removed {Đã xoá}: use ESLint or Biome directly {dùng ESLint hoặc Biome trực tiếp}
  4. AMP removed {Đã xoá}: all AMP APIs and config gone {tất cả AMP API và config bị xoá}
  5. Node.js 18 dropped {Bỏ Node 18}: minimum is 20.9 {tối thiểu là 20.9}
  6. Async params strictly enforced {Params bất đồng bộ bắt buộc}: no more deprecation warnings, just errors {không còn cảnh báo, chỉ có lỗi}
  7. revalidateTag() requires cache profile {revalidateTag() yêu cầu cache profile}
  8. publicRuntimeConfig/serverRuntimeConfig removed {Đã xoá}: use env variables {dùng biến môi trường}
// v15 → v16 migration for middleware

// Before: middleware.ts
export function middleware(request: NextRequest) { /* ... */ }

// After: proxy.ts
export function proxy(request: NextRequest) { /* ... */ }
// v15 → v16 migration for caching

// Before (v15): per-fetch caching
const data = await fetch(url, {
  cache: 'force-cache',
  next: { revalidate: 3600, tags: ['products'] },
});

// After (v16): use cache directive
async function getProducts() {
  'use cache';
  cacheLife('hours');
  cacheTag('products');
  const res = await fetch(url);
  return res.json();
}

Server Actions {Server Actions}

Server Actions let you run server code from client components {Server Actions cho phép bạn chạy code server từ client component} — like calling an API without building an API {như gọi API mà không cần xây API}:

// app/actions.ts
'use server';

import { revalidateTag } from 'next/cache';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  await db.insert({ title, content });
  revalidateTag('posts');
}
// app/new-post/page.tsx
import { createPost } from '../actions';

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="Title" />
      <textarea name="content" placeholder="Content" />
      <button type="submit">Publish</button>
    </form>
  );
}

Evolution across versions {Phát triển qua các phiên bản}:

  • v14: Stable, basic form handling {Ổn định, xử lý form cơ bản}
  • v15: Enhanced with useActionState, better error handling {Cải thiện với useActionState, xử lý lỗi tốt hơn}
  • v16: Mature, integrates with use cache for optimistic updates {Trưởng thành, tích hợp với use cache cho optimistic update}

Migration Cheat Sheet {Bảng tóm tắt di chuyển}

From Pages Router to App Router {Từ Pages Router sang App Router}

Pages RouterApp Router Equivalent
pages/index.tsxapp/page.tsx
pages/blog/[slug].tsxapp/blog/[slug]/page.tsx
pages/_app.tsxapp/layout.tsx
pages/_document.tsxapp/layout.tsx (html, body)
pages/api/hello.tsapp/api/hello/route.ts
getStaticPropsJust fetch in Server Component {Chỉ cần fetch trong Server Component}
getStaticPathsgenerateStaticParams()
getServerSidePropsFetch with cookies()/headers() or dynamic = 'force-dynamic'
useRouter() (next/router)useRouter() (next/navigation) + usePathname() + useSearchParams()

Quick Upgrade Commands {Lệnh nâng cấp nhanh}

# Upgrade to v15 from v14
npx @next/codemod@latest upgrade 15

# Upgrade to v16 from v15
npx @next/codemod@canary upgrade latest

Conclusion {Kết luận}

The journey from Pages Router to App Router (v16) {Hành trình từ Pages Router đến App Router (v16)} represents a fundamental shift in how we think about React applications {đại diện cho sự thay đổi căn bản trong cách chúng ta nghĩ về ứng dụng React}:

  • Routing {Routing}: from flat files to nested layouts with parallel routes {từ file phẳng đến layout lồng nhau với route song song}
  • Rendering {Rendering}: from “pick one strategy per page” to “mix strategies within a single page” (PPR) {từ “chọn một chiến lược mỗi trang” sang “kết hợp chiến lược trong một trang” (PPR)}
  • Caching {Caching}: from “trust the framework” to “be explicit about what you want” {từ “tin framework” sang “nói rõ bạn muốn gì”}
  • Data {Data}: from special functions to just… async components {từ các hàm đặc biệt sang chỉ… component async}

If you’re starting fresh in 2026 {Nếu bạn bắt đầu mới năm 2026}: use Next.js 16 with the App Router {dùng Next.js 16 với App Router}. The Pages Router still works but receives no new features {Pages Router vẫn hoạt động nhưng không có tính năng mới}.

If you’re migrating {Nếu bạn đang di chuyển}: go v14 → v15 → v16 incrementally {đi từng bước v14 → v15 → v16}. Each version’s codemod handles most of the work {Codemod mỗi phiên bản xử lý hầu hết công việc}. The App Router and Pages Router can coexist in the same project {App Router và Pages Router có thể cùng tồn tại trong một project}.


Resources {Tài liệu tham khảo}