· nextjs / app-router / pages-router
Cách chuyển Next.js Pages Router sang App Router 2026
Chuyển Next.js Pages Router sang App Router trên v16.2.6: root layout, data fetching, Route Handlers, metadata API, và 8 vấn đề thực tế kèm cách xử lý.
Bởi Ethan
2.994 từ · 15 phút đọc
Nếu dự án Next.js của bạn vẫn đang dùng Pages Router, thì App Router mang lại React Server Components, streaming, parallel routes, và một mô hình data fetching không còn phụ thuộc vào ba hàm khác nhau tùy theo loại dữ liệu. Đáng để chuyển. Tuy nhiên cũng không đơn giản — hãy tính khoảng 4–8 giờ cho mỗi route trong lần đầu tiên.
Chiến lược coexistence là điểm mấu chốt: pages/ và app/ có thể chạy song song. Bạn chuyển từng route một. Không có gì bị hỏng trong quá trình thực hiện.
Bài này dành cho ai
Các team đang chạy Next.js 13.4 trở lên và muốn thoát khỏi Pages Router. Bạn cần đã quen với TypeScript và các kiến thức cơ bản về Next.js. Nếu bạn đang dùng Next.js 12 hoặc thấp hơn, hãy upgrade lên 16 trước bằng npx @next/codemod@latest upgrade — chạy codemod này trước khi migration giúp bạn tránh được các vấn đề theo phiên bản chồng chất lên các vấn đề về router model.
Nếu bạn có setup micro-frontend với module federation, guide này không bao gồm trường hợp đó.
Nếu bạn vẫn đang cân nhắc có nên tiếp tục dùng Next.js không, hãy xem SvelteKit vs Next.js 2026 và Next.js 16 vs React Router v7 trước khi quyết định đầu tư công sức cho việc migration.
Môi trường thử nghiệm
- Next.js 16.2.6 — phiên bản stable hiện tại tính đến tháng 5/2026
- Node.js 22.14.0 — phiên bản tối thiểu được hỗ trợ là v20.9.0
- TypeScript 5.8.3
- Dự án tham chiếu: một ứng dụng cỡ vừa với SSR product pages, các blog post tĩnh, một form nhiều bước, và bốn API endpoint
Bản đồ khái niệm — Next.js Pages Router vs App Router
Trước khi động vào bất kỳ file nào, hãy hiểu sự thay đổi về mô hình:
| Pages Router | App Router |
|---|---|
pages/index.tsx | app/page.tsx |
pages/about.tsx | app/about/page.tsx |
pages/_app.tsx | app/layout.tsx |
pages/_document.tsx | app/layout.tsx (gộp chung) |
getServerSideProps | async Server Component (hoặc fetch không cache) |
getStaticProps | async Server Component với "use cache" |
getStaticPaths | generateStaticParams |
pages/api/hello.ts | app/api/hello/route.ts |
next/head | export const metadata |
useRouter từ next/router | useRouter từ next/navigation |
Thay đổi về data fetching là bước chuyển tư duy lớn nhất. Trong Pages Router, data fetching diễn ra ở các hàm đặc biệt được export từ cùng file với component. Trong App Router, mặc định mỗi component là một Server Component — bạn await dữ liệu trực tiếp trong thân component.
Danh sách kiểm tra trước khi migration
Chạy các lệnh này trước khi tạo bất kỳ file mới nào:
node -v # phải ≥ 20.9.0
npx next --version # phải ≥ 13.4.0 để coexistence hoạt động
Kiểm tra các dependency có giả định về Pages Router:
npx next info
Những điểm cần chú ý trước khi bắt đầu:
- Package nào bọc
getServerSidePropshoặcgetInitialProps— những cái này không tự động chuyển được _document.tsxtùy chỉnh với các thuộc tính<body>không chuẩn — bạn sẽ chuyển chúng vàoapp/layout.tsxnext-i18next— đổi sangnext-intl, thư viện có hỗ trợ App Router đầy đủ- Các thư viện session dùng
getServerSidePropsđể kiểm soát auth — bạn sẽ thay bằng Middleware hoặcauth()trong Server Components
Bước 1 — Tạo thư mục app/
Tạo app/ ở root của project cạnh pages/. Next.js tự động phát hiện thư mục app/; bạn không cần thay đổi gì trong config.
my-app/
├── app/ ← mới
│ └── layout.tsx ← cần tạo ngay
├── pages/ ← giữ lại tạm thời
│ ├── index.tsx
│ └── about.tsx
└── next.config.ts
app/layout.tsx là bắt buộc ngay khi thư mục app/ tồn tại. Không có nó, Next.js sẽ báo lỗi.
Bước 2 — Chuyển root layout
Root layout trong Pages Router nằm ở hai file: pages/_app.tsx (global providers, global styles) và pages/_document.tsx (HTML shell, mặc định <head>, thuộc tính body). Cả hai gộp vào app/layout.tsx.
Trước — pages/_app.tsx:
import type { AppProps } from 'next/app';
import '../styles/globals.css';
export default function App({ Component, pageProps }: AppProps) {
return (
<div className="app-shell">
<Component {...pageProps} />
</div>
);
}
Trước — pages/_document.tsx:
import { Html, Head, Main, NextScript } from 'next/document';
export default function Document() {
return (
<Html lang="en">
<Head>
<link rel="icon" href="/favicon.ico" />
</Head>
<body className="bg-gray-50">
<Main />
<NextScript />
</body>
</Html>
);
}
Sau — app/layout.tsx:
import type { Metadata } from 'next';
import '../styles/globals.css';
export const metadata: Metadata = {
icons: { icon: '/favicon.ico' },
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className="bg-gray-50">
<div className="app-shell">{children}</div>
</body>
</html>
);
}
Nếu bạn có Redux store global, React Query provider, hoặc theme provider, hãy bọc children ở đây — nhưng đánh dấu layout là 'use client' nếu các provider đó dùng hooks bên trong. Tốt hơn: tách providers thành một client component Providers và import vào server layout:
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
// app/layout.tsx
import { Providers } from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
Cách này giữ layout ở dạng Server Component trong khi vẫn cho phép các thư viện client-side hoạt động.
Bước 3 — Chuyển từng page một
Bắt đầu với route ít phức tạp nhất — thường là một trang tĩnh không có data fetching. Chuyển xong, kiểm tra hoạt động, rồi mới đến trang có data fetching.
Trước — pages/about.tsx:
export default function AboutPage() {
return <main><h1>About us</h1></main>;
}
Sau — app/about/page.tsx:
export default function AboutPage() {
return <main><h1>About us</h1></main>;
}
Trong thời gian coexistence, nếu cùng một route tồn tại ở cả pages/about.tsx và app/about/page.tsx, Next.js cảnh báo trong môi trường development và dùng app/ trong production. Khi đã xác nhận phiên bản App Router hoạt động, hãy xóa phiên bản trong pages/.
Dynamic routes
Trước — pages/products/[id].tsx:
import { GetStaticPaths, GetStaticProps } from 'next';
export const getStaticPaths: GetStaticPaths = async () => {
const ids = await fetchProductIds();
return {
paths: ids.map((id) => ({ params: { id: String(id) } })),
fallback: 'blocking',
};
};
export const getStaticProps: GetStaticProps = async ({ params }) => {
const product = await fetchProduct(params!.id as string);
return { props: { product }, revalidate: 60 };
};
export default function ProductPage({ product }: { product: Product }) {
return <ProductDetail product={product} />;
}
Sau — app/products/[id]/page.tsx:
import { fetchProduct, fetchProductIds } from '@/lib/api';
export async function generateStaticParams() {
const ids = await fetchProductIds();
return ids.map((id) => ({ id: String(id) }));
}
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await fetchProduct(id);
return <ProductDetail product={product} />;
}
Lưu ý params là Promise trong Next.js 16 — bạn phải await nó. Đây là một vấn đề thường gặp được đề cập bên dưới.
Để có hành vi fallback: 'blocking', thêm export này vào file page:
export const dynamicParams = true; // mặc định — tạo params còn thiếu theo request
Để có fallback: false (trả về 404 với params không biết), đặt dynamicParams = false.
Bước 4 — Thay thế các pattern data fetching
getServerSideProps → async Server Component
// Trước
export const getServerSideProps = async ({ params }) => {
const data = await fetchData(params.id);
return { props: { data } };
};
export default function Page({ data }) { ... }
// Sau
export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const data = await fetchData(id);
return <YourComponent data={data} />;
}
Trong Next.js 16, fetch mặc định không cache. Điều này khớp với hành vi ban đầu của getServerSideProps (chạy mỗi request). Không cần cấu hình thêm.
getStaticProps → directive "use cache"
Trước khi dùng "use cache" với cacheLife, hãy bật flag cacheComponents trong next.config.ts:
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
cacheComponents: true,
};
export default nextConfig;
Nếu thiếu flag này, "use cache" + cacheLife sẽ thất bại âm thầm — dữ liệu bị fetch ở mỗi request.
// Trước
export const getStaticProps = async () => {
const posts = await fetchBlogPosts();
return { props: { posts }, revalidate: 3600 };
};
// Sau
import { cacheLife } from 'next/cache';
export default async function BlogIndex() {
'use cache';
cacheLife('hours');
const posts = await fetchBlogPosts();
return <PostList posts={posts} />;
}
cacheLife('hours') tương ứng với một profile revalidate khoảng mỗi giờ. Các profile có sẵn: 'seconds', 'minutes', 'hours', 'days', 'weeks', 'max'. Để kiểm soát TTL chính xác: cacheLife({ revalidate: 3600, expire: 86400 }).
getStaticPaths → generateStaticParams
Đã trình bày ở Bước 3. Các điểm khác biệt chính:
- Trả về trực tiếp mảng các param object (không phải
{ paths: [...] }) - Không có key
fallback— dùng exportdynamicParamsthay thế - Không có argument
context— đây là hàm async độc lập
Bước 5 — Chuyển API routes thành Route Handlers
Trước — pages/api/products.ts:
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
const products = await fetchProducts();
res.status(200).json(products);
}
Sau — app/api/products/route.ts:
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const products = await fetchProducts();
return NextResponse.json(products);
}
Route Handlers dùng các named export (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS) thay vì một default export duy nhất với phân nhánh theo method. Các method không được export tự động trả về 405.
Với dynamic route segments:
// app/api/products/[id]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const product = await fetchProduct(id);
if (!product) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json(product);
}
Bước 6 — Thay next/head bằng metadata API
Trước — trong bất kỳ file page nào:
import Head from 'next/head';
export default function ProductPage({ product }: { product: Product }) {
return (
<>
<Head>
<title>{product.name} — MyShop</title>
<meta name="description" content={product.description} />
<meta property="og:title" content={product.name} />
</Head>
<ProductDetail product={product} />
</>
);
}
Sau — static metadata:
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'About — MyShop',
description: 'Our story and team.',
};
export default function AboutPage() { ... }
Sau — dynamic metadata:
import type { Metadata } from 'next';
import { fetchProduct } from '@/lib/api';
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
const product = await fetchProduct(id);
return {
title: `${product.name} — MyShop`,
description: product.description,
openGraph: {
title: product.name,
images: [product.imageUrl],
},
};
}
Next.js tự động loại bỏ trùng lặp metadata giữa các nested layout.
Các vấn đề thường gặp và cách xử lý
1. Import path của useRouter đã thay đổi
// Sai — âm thầm làm hỏng client-side navigation
import { useRouter } from 'next/router';
// Đúng
import { useRouter } from 'next/navigation';
Router next/navigation cũng có API khác: router.push vẫn hoạt động, nhưng router.query không còn tồn tại. Dùng useSearchParams() cho query params và useParams() cho dynamic segments.
2. params và searchParams là Promise trong Next.js 16
// Code này sẽ lỗi runtime trong Next.js 16
export default function Page({ params }: { params: { id: string } }) {
const { id } = params; // TypeError: params is not an object
}
// Đúng
export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
}
Trong client components không thể dùng await, hãy dùng React.use():
'use client';
import { use } from 'react';
export default function ClientPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
return <div>{id}</div>;
}
3. cookies() và headers() là async
// Sai
import { cookies } from 'next/headers';
const token = cookies().get('token');
// Đúng
import { cookies } from 'next/headers';
const cookieStore = await cookies();
const token = cookieStore.get('token');
4. Ranh giới Client Component — đặt 'use client' ở đâu
Mỗi component dùng hooks (useState, useEffect, useContext) hoặc browser API đều cần 'use client'. Directive này chỉ cần đặt ở đầu file nơi hook/API xuất hiện lần đầu — các component con tự động được coi là client component.
Đừng rải 'use client' khắp nơi. Giữ Server Components càng sâu trong cây component càng tốt; đẩy ranh giới xuống các leaf node tương tác.
5. Global CSS chỉ được import trong app/layout.tsx
Trong Pages Router, bạn có thể import global CSS trong _app.tsx. Trong App Router, import global CSS chỉ được phép trong layout files, không phải trong page files hay components. Nếu thử import globals.css trong file không phải layout, bạn sẽ gặp:
Error: Global CSS cannot be imported from files other than your Custom <App>.
Chuyển tất cả global import vào app/layout.tsx.
6. Middleware bị deprecated trong Next.js 16 — đổi tên thành proxy.ts
Trong Next.js 16, middleware.ts bị deprecated và thay bằng proxy.ts. File cũ vẫn chạy (chưa bị xóa), nên không có gì bị hỏng ngay — nhưng khi migration lên Next.js 16 thì nên xử lý luôn.
Đổi tên middleware.ts → proxy.ts ở root của project. Đổi tên hàm export từ middleware thành proxy:
// Trước: middleware.ts
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
// logic của bạn
return NextResponse.next();
}
// Sau: proxy.ts
import { NextRequest, NextResponse } from 'next/server';
export function proxy(request: NextRequest) {
// logic của bạn
return NextResponse.next();
}
File phải đặt ở root của project — chuyển vào app/proxy.ts sẽ âm thầm vô hiệu hóa nó.
7. getInitialProps chặn hoàn toàn static optimization
Nếu bạn chuyển một page dùng getInitialProps, App Router không có tương đương — không có getInitialProps trong RSC. Nếu một thư viện bạn phụ thuộc thêm getInitialProps (một số thư viện auth cũ làm vậy), bạn cần refactor hoặc thay thế thư viện đó. Codemod sẽ đánh dấu những trường hợp này nhưng không thể tự sửa.
8. Caching mặc định thay đổi — không cache theo mặc định
Trong Pages Router, các trang getStaticProps được generate tĩnh theo mặc định. Trong Next.js 16, fetch() trong Server Components không cache theo mặc định. Nếu bạn chuyển một trang với kỳ vọng nó là tĩnh nhưng không thêm "use cache", mỗi request sẽ đánh vào data source của bạn.
Kiểm tra Vercel Function invocation logs sau khi migration — các spike bất thường thường chỉ ra vấn đề này.
Deploy trên Vercel
App Router hoạt động trên Vercel mà không cần thay đổi config. Incremental migration được hỗ trợ rõ ràng: các route trong pages/ deploy dưới dạng Lambda function, các route trong app/ deploy dưới dạng Edge hoặc Node.js function tùy theo setting runtime của bạn.
Sau khi deploy incremental migration, kiểm tra:
- Tab Functions: các App Router route phải xuất hiện dưới dạng function riêng biệt, không gộp chung với Pages Router routes
- Tab Logs: filter theo route để xác nhận các pattern
await paramsvàawait cookies()không bị lỗi
Để buộc một route chạy Edge runtime (độ trễ thấp nhất, không có Node.js API):
// app/some-route/page.tsx
export const runtime = 'edge';
Không có export này thì mặc định là Node.js runtime.
Kết luận
Các team đang nâng cấp build tooling cùng lúc có thể kết hợp với hướng dẫn migrate từ Webpack sang Vite để xử lý cả hai trong một sprint.
Chuyển từng bước. Bắt đầu với các trang tĩnh, rồi đến trang có data fetching, rồi đến API routes. Chiến lược coexistence cho phép bạn đưa App Router lên production từng route một mà không cản trở team.
Điểm mất thời gian nhất là pattern async params / cookies() / headers() — nó khác với mọi phiên bản Next.js trước đó và TypeScript types không phải lúc nào cũng bắt được lỗi trước khi chạy. Chạy codemod trước (npx @next/codemod@latest upgrade), kiểm tra kỹ output, và test từng route đã chuyển trước khi sang route tiếp theo.
Migration đầy đủ cho một app 20 route mất khoảng 2–4 ngày. Kết quả là data fetching đơn giản hơn, streaming thực sự, và access vào các RSC pattern giúp giảm đáng kể client bundle size trên các trang nặng dữ liệu.
Lưu ý
- Đã thử nghiệm trên Next.js 16.2.6 — hành vi
paramslàPromiseđược giới thiệu từ v15 và có thể sẽ giữ nguyên trong suốt vòng đời của App Router. Nếu bạn đang ở 13.x,paramsvẫn là plain object. - Chúng tôi không thử nghiệm với i18n routing. Nếu bạn dùng
next-i18next, hãy đọc riêng migration guide của nó — câu chuyện i18n của App Router khác biệt. - Module federation nằm ngoài phạm vi hoàn toàn.