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.

Giới thiệu

Nếu bạn chỉ mới dùng Next.js với Pages Router, bài này dành cho bạn. Framework đã thay đổi rất nhiều từ phiên bản 14 đến 16, 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.

Chúng ta sẽ đi qua:

  • cách routing dựa trên file hoạt động trong App Router
  • các chiến lược render
  • cách caching thay đổi từ “cache mọi thứ” sang “không cache gì” sang “cache có chủ đích”
  • những gì thay đổi, cải thiện qua các phiên bản

Bài viết sử dụng định dạng song ngữ: tiếng Anh trước, tiếng Việt trong ngoặc nhọn {}. Đọc tự nhiên bằng tiếng Anh, liếc qua tiếng Việt để hiểu ngữ cảnh.


Pages Router và App Router

Thế giới cũ: Pages Router

Trong Pages Router, 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

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>;
}

Vấn đề của mô hình này:

  • Tất cả component mặc định là client component — chúng gửi JavaScript đến trình duyệt
  • Lấy dữ liệu chỉ ở cấp page — bạn không thể fetch trong component lồng nhau mà không truyền prop hoặc fetch phía client
  • Layout cần giải pháp tạm — không có nested layout native

Thế giới mới: App Router

Được giới thiệu ở Next.js 13, ổn định ở 14, 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

Những thay đổi mô hình chính:

Pages RouterApp Router
Tất cả là Client ComponentMặc định là Server Component
getStaticProps / getServerSidePropsChỉ cần hàm async — fetch trực tiếp trong component
Không có nested layoutNested layout native
_app.tsx + _document.tsxlayout.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>;
}

Chú ý: không cần getStaticProps, không cần export default function nhận props từ hàm data. Component chính nó LÀ tầng data.


Routing chi tiết

Quy ước file

App Router dùng tên file đặc biệt để định nghĩa cấu trúc UI:

FileMục đích
page.tsxUI cho route — làm route có thể truy cập
layout.tsxUI dùng chung bọc children — tồn tại qua các lần navigate
loading.tsxUI loading hiển thị khi content đang stream
error.tsxError boundary cho route segment
not-found.tsxUI cho 404 trong segment
template.tsxGiống layout nhưng re-mount khi navigate
default.tsxUI mặc định cho parallel route
route.tsAPI endpoint

Layouts — Tính năng đỉnh nhất

Layout tồn tại xuyên suốt các lần chuyển trang. 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>
  );
}

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

Nhóm route

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

Điều này cho phép bạn có nhiều root layout — header/footer khác nhau cho các phần khác nhau của app.

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>;
}

Route song song

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>
  );
}

Trường hợp sử dụng: dashboard, modal giữ URL state, render có điều kiện dựa trên auth.

Route chặn

Hiển thị route trong ngữ cảnh khác — 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)

Quy ước: (.)

Middleware → Proxy (v16)

Ở 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*'],
};

Ở v16, đổi thành proxy.ts — 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*'],
};

Tại sao đổi tên? “gợi ý nó có thể làm mọi thứ. Thực tế, nó là proxy mạng — nó chặn request trước khi chúng đến app. Tên mới làm rõ vai trò. 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.


Các chiến lược Render

Đây là nơi Next.js trở nên phức tạp nhưng mạnh mẽ. Hãy đi qua từng chiến lược:

Server Component và Client Component

Khối xây dựng cơ bản

// 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>
  );
}

Khi nào dùng cái nào:

Server ComponentClient Component
Lấy dữ liệuXử lý sự kiện
Truy cập backend trực tiếpuseState, useEffect, hooks
Giữ bí mật an toànBrowser APIs (localStorage, window)
Giảm bundle phía clientThư viện tương tác bên thứ 3

Tạo trang tĩnh

Trang được render lúc build. 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>;
}

Khi nào được chọn: Next.js tự động dùng SSG khi page không có hàm động và tất cả data có thể cache.

Tương đương ở Pages Router: getStaticProps + getStaticPaths.

Render phía server

Trang được render mỗi request. 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} />;
}

Bạn cũng có thể ép SSR rõ ràng:

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

Tương đương ở Pages Router: getServerSideProps.

Tái tạo tĩnh tăng dần

Tốt nhất của cả hai: 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} />;
}

Cách hoạt động:

  1. Request đầu → phục vụ bản cache
  2. Sau 60s, request tiếp vẫn phục vụ cache NHƯNG kích hoạt tái tạo nền
  3. Khi tái tạo xong, bản mới thay thế cache cũ

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 });
}

Tương đương ở Pages Router: getStaticProps

Render server theo luồng

Thay vì đợi tất cả data trước khi gửi HTML, 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} />;
}

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 />;
}

Render phía client

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} />;
}

Render trước một phần

Tương lai của rendering. 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 qua các phiên bản:

  • Cờ thử nghiệm
  • Ổn định, bật từng route
  • Chiến lược render mặc định — PPR thay thế lựa chọn nhị phân SSG/SSR

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 — Sự thay đổi lớn nhất

Caching là thứ thay đổi nhiều nhất qua các phiên bản. Hiểu sự thay đổi này là rất quan trọng.

v14: Cache mọi thứ mặc định

Ở 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',
});

Bốn tầng cache ở v14:

LayerCache cái gìThời gian
Request MemoizationCác lệnh fetch() trùng trong cùng renderMỗi request
Data Cachefetch() Response fetch() trên serverVĩnh viễn (đến khi revalidate)
Full Route CacheToàn bộ HTML + RSC payload đã renderPersistent (static routes)
Router CacheRSC payload trên client để navigateTheo session

Vấn đề: dev bị bối rối. Data cũ mà không biết tại sao. Caching ẩn khiến debug như ác mộng.

v15: Không cache gì mặc định

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 },
});

Đây là breaking change. App dựa vào cache ẩn bỗng chậm đi vì mọi request đều đến server gốc.

v16: Cache có chủ đích với use cache

Next.js 16 giới thiệu Cache Component — 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;

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} />;
}

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]);
}

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>
  );
}

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 ở 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

Bảng so sánh Caching

Hành viv14v15v16
fetch() defaultCó cacheKhông cacheNot cached (use 'use cache' to opt in)
Opt-in mechanismcache: 'no-store' to disablecache: 'force-cache' to enable'use cache' directive
Độ chi tiếtPer-fetchPer-fetchFile / Function / Component
Cache profilesN/AN/AcacheLife()
Tag-based invalidationrevalidateTag()revalidateTag()revalidateTag() (SWR) + updateTag()
Mô hình tư duy”Mọi thứ cache trừ khi nói không”Không gì cache trừ khi nói có”Đánh dấu cái cần cache, kiểm soát bao lâu

So sánh phiên bản

Bức tranh toàn cảnh

Tính năngNext.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 defaultTích cựcChủ độngRõ ràng
Request APIsSynchronousBất đồng bộ (có cảnh báo)Bắt buộc bất đồng bộ
Middlewaremiddleware.ts (Edge)middleware.ts (Edge)proxy.ts (Node.js)
PPRExperimentalStable (opt-in)Mặc định
Server ActionsStableEnhanced with validationTrưởng thành
React CompilerN/AExperimentalStable (opt-in)
Node.js minimum18.1718.1820.9
next lintAvailableAvailableĐã xoá
AMP supportKhông khuyến khíchDeprecatedĐã xoá
Build speedBaseline2-3x faster (Turbopack dev)5-10x faster (Turbopack prod)

Breaking Change mỗi phiên bản

Thay đổi gây lỗi từ v14 sang v15

  1. Caching đảo ngược: fetch() không còn cache mặc định
  2. API Request bất đồng bộ: cookies(), headers(), params, searchParams trở thành Promise
  3. React 19:
// 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>;
}

Thay đổi gây lỗi từ v15 sang v16

  1. Turbopack mặc định: config Webpack tuỳ chỉnh cần cờ --webpack
  2. Đổi tên: khác tên file VÀ tên hàm
  3. Đã xoá: dùng ESLint hoặc Biome trực tiếp
  4. Đã xoá: tất cả AMP API và config bị xoá
  5. Bỏ Node 18: tối thiểu là 20.9
  6. Params bất đồng bộ bắt buộc: không còn cảnh báo, chỉ có lỗi
  7. revalidateTag() yêu cầu cache profile
  8. Đã xoá: 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 cho phép bạn chạy code server từ client component — 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>
  );
}

Phát triển qua các phiên bản:

  • Ổn định, xử lý form cơ bản
  • Cải thiện với useActionState, xử lý lỗi tốt hơn
  • Trưởng thành, tích hợp với use cache cho optimistic update

Bảng tóm tắt di chuyển

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
getStaticPropsChỉ cần fetch trong Server Component
getStaticPathsgenerateStaticParams()
getServerSidePropsFetch with cookies()/headers() or dynamic = 'force-dynamic'
useRouter() (next/router)useRouter() (next/navigation) + usePathname() + useSearchParams()

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

Kết luận

Hành trình từ Pages Router đến App Router (v16) đại diện cho sự thay đổi căn bản trong cách chúng ta nghĩ về ứng dụng React:

  • Routing: từ file phẳng đến layout lồng nhau với route song song
  • Rendering: 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: từ “tin framework” sang “nói rõ bạn muốn gì”
  • Data: từ các hàm đặc biệt sang chỉ… component async

Nếu bạn bắt đầu mới năm 2026: dùng Next.js 16 với App Router. Pages Router vẫn hoạt động nhưng không có tính năng mới.

Nếu bạn đang di chuyển: đi từng bước v14 → v15 → v16. Codemod mỗi phiên bản xử lý hầu hết công việc. App Router và Pages Router có thể cùng tồn tại trong một project.


Tài liệu tham khảo

  • Tài liệu chính thức
  • Hướng dẫn nâng cấp từng bước
  • Hiểu RSC từ tài liệu React
  • Chọn chiến lược phù hợp