Migration Pages Router sang App Router trong Next.js 16: Playbook Từng Bước (2026)

Migration Pages Router sang App Router trong Next.js 16 là quy trình incremental 2-6 tuần với dự án cỡ vừa. Codemod tự động 80-90% thay đổi cơ học, còn lại là refactor data fetching. Playbook đầy đủ từ 5 dự án thực tế.

Cập nhật: 15 tháng 8, 2026

Migration từ Pages Router sang App Router trong Next.js 16 là một quy trình incremental (từng bước). Bạn giữ nguyên thư mục pages/ đang chạy production, tạo thư mục app/ song song, rồi chuyển từng route một trong khoảng 2–6 tuần với dự án cỡ vừa. Codemod chính thức @next/codemod tự động hóa khoảng 80–90% các thay đổi cơ học (async params, đổi import, chuyển API Routes), phần còn lại là refactor data-fetching từ getServerSideProps sang async Server Components. Bài này là playbook đầy đủ mà tôi đã dùng cho ba team migration trong năm qua.

  • Pages Router chưa bị deprecate tính đến Next.js 16. Không có timeline gỡ bỏ chính thức, nhưng feature mới chỉ được thêm vào App Router.
  • Chạy lệnh npx @next/codemod@canary upgrade latest để tự động chuyển async request APIs, đổi next/router thành next/navigation, và di chuyển API Routes.
  • Thư mục pages/app/ có thể tồn tại song song trong cùng một dự án, cho phép migrate từng route một mà không cần big-bang rewrite.
  • getServerSideProps được thay bằng async Server Components; getStaticPaths đổi thành generateStaticParams; _app.tsx cộng _document.tsx gộp vào app/layout.tsx.
  • Trong Next.js 16, middleware.ts được đổi tên thành proxy.ts. Codemod xử lý luôn khi bạn upgrade.
  • Ước tính effort: dự án nhỏ (<20 routes) 3–5 ngày, cỡ vừa (20–100 routes) 2–4 tuần, cỡ lớn (>100 routes) 6–12 tuần.

Pages Router có bị deprecate trong Next.js 16 không?

Câu trả lời ngắn: Không. Tính đến bản Next.js 16 (phát hành cuối 2025), Pages Router vẫn được hỗ trợ chính thức, vẫn có mặt trong docs, và vẫn nhận bug fix. Vercel chưa thông báo timeline gỡ bỏ và có lẽ sẽ không thông báo trong 2–3 năm tới. Đơn giản vì quá nhiều code production đang chạy trên nó.

Tuy nhiên, thực tế đằng sau con số đó khắc nghiệt hơn. Từ Next.js 13.4 (2023), tất cả feature mới chỉ được thêm vào App Router: Server Components, Server Actions, Partial Prerendering, Cache Components, React Compiler auto-memoization, streaming với Suspense, Turbopack builds tối ưu hơn. Pages Router giống trạng thái "duy trì" của AngularJS trước khi ngừng hỗ trợ hoàn toàn. Nó hoạt động, nhưng bạn đang trượt xa khỏi hệ sinh thái.

Đặc biệt trong Next.js 16, một breaking change ảnh hưởng cả hai router là middleware.ts được đổi tên thành proxy.ts. Nếu bạn đang lên plan upgrade lên Next 16, hãy đọc bài chuyển đổi middleware sang proxy trong Next.js 16 trước. Thay đổi đó độc lập với việc migrate router.

Bảng so sánh: Pages Router vs App Router

Trước khi bắt tay vào code, dưới đây là bảng đối chiếu mà tôi in ra dán cạnh bàn khi làm migration đầu tiên. Nó giúp cả team nói cùng một ngôn ngữ khi review PR.

Khía cạnh Pages Router App Router (Next.js 16)
Thư mục pages/ app/
File route pages/about.tsx app/about/page.tsx
Loại component mặc định Client Component (mọi thứ hydrate) Server Component (opt-in 'use client')
Data fetching SSR getServerSideProps async function Page() + fetch()
Data fetching SSG getStaticProps + getStaticPaths generateStaticParams + fetch cache
API endpoints pages/api/*.ts app/api/*/route.ts (Route Handlers)
Global layout _app.tsx + _document.tsx app/layout.tsx (root layout)
Loading state Không có convention loading.tsx + Suspense
Error boundary _error.tsx error.tsx per-route
Router hook useRouter từ next/router useRouter từ next/navigation

Điểm khác biệt căn bản nhất không nằm ở file convention. Nó nằm ở model rendering. Pages Router chạy toàn bộ page trên cả server (pre-render) và client (hydrate). App Router chia rõ ranh giới: Server Components chạy chỉ trên server (không có JS payload gửi xuống browser), Client Components chạy trên cả hai. Chuyển đổi mindset này là phần khó hơn cả việc gõ code.

Checklist chuẩn bị trước khi migrate

Đừng chạy codemod trên nhánh main. Đây là các bước tôi luôn làm trước khi mở terminal để chạy migration đầu tiên:

  1. Pin phiên bản toolchain: Node.js 20.9.0+ (Node 18 đã bị bỏ hỗ trợ trong Next 16), TypeScript 5.1+, npm 10+ hoặc pnpm 9+.
  2. Bump Next.js lên bản mới nhất trong Pages Router trước: npm install next@latest react@latest react-dom@latest. Sửa hết lỗi build trước khi động vào App Router (không trộn hai loại lỗi lại với nhau).
  3. Bật strict mode trong tsconfig.json: App Router bắt buộc async params, TypeScript sẽ giúp bạn tìm chỗ quên await.
  4. Tạo nhánh migration riêng: git checkout -b migrate/app-router. Không squash merge cho tới khi CI xanh.
  5. Chụp baseline metrics: Lighthouse LCP, bundle size, thời gian build. Sau migration bạn cần số liệu để chứng minh performance tốt hơn (hoặc rollback nếu tệ hơn).
  6. Audit dependencies: Chạy npx npm-check-updates. Các package như next-auth, swr, @tanstack/react-query đã có phiên bản tương thích App Router, nâng cấp trước.

Chạy Codemod: tự động hóa 80% công việc

Next.js cung cấp codemod chính thức xử lý hầu hết các thay đổi cơ học. Đây là công cụ duy nhất bạn cần chạy đầu tiên:

# Chạy codemod upgrade - tự động bump dependencies + transform code
npx @next/codemod@canary upgrade latest

# Hoặc chạy từng transform riêng biệt để review dần
npx @next/codemod@latest next-async-request-api .
npx @next/codemod@latest next-request-geo-ip .
npx @next/codemod@latest built-in-next-font .

Codemod xử lý được các mục sau (nguồn: Next.js 16 upgrade guide):

  • Chuyển sync params, searchParams, cookies(), headers(), draftMode() sang async.
  • Đổi tên middleware.ts thành proxy.ts và cập nhật config tương ứng.
  • Thay import { useRouter } from 'next/router' bằng import { useRouter } from 'next/navigation' trong các file đã ở dưới app/.
  • Chuyển getStaticPaths sang generateStaticParams.
  • Port pages/api/* sang app/api/*/route.ts khi được yêu cầu.

Sau khi chạy xong, luôn commit ngay với message chore: run @next/codemod upgrade để review dễ hơn. Đừng edit đè lên diff của codemod trong cùng commit, vì bạn sẽ mất khả năng phân biệt lỗi do codemod hay do bạn tự sửa.

Codemod đạt tỉ lệ thành công khoảng 90% cho dự án chuẩn, giảm dần với business logic phức tạp. Việc còn lại (bao gồm refactor data fetching, xử lý auth logic tự custom, và điều chỉnh các loader Webpack không tương thích Turbopack) vẫn cần bạn ngồi làm bằng tay.

Migrate _app.tsx và _document.tsx sang app/layout.tsx

Trong Pages Router, bạn có hai file config toàn cục: _document.tsx control <html>/<head>, và _app.tsx wrap providers. App Router gộp cả hai vào một file duy nhất: app/layout.tsx.

File _document.tsx cũ:

// pages/_document.tsx
import { Html, Head, Main, NextScript } from 'next/document';

export default function Document() {
  return (
    <Html lang="vi">
      <Head />
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

Chuyển thành app/layout.tsx:

// app/layout.tsx - Root layout (Server Component)
import type { Metadata } from 'next';
import './globals.css';

export const metadata: Metadata = {
  title: 'My App',
  description: 'Migrated from Pages Router',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="vi">
      <body>{children}</body>
    </html>
  );
}

Providers từ _app.tsx (Redux, TanStack Query, Auth context) cần được extract vào một Client Component riêng vì chúng dùng React context, không chạy được trên Server Component:

// app/providers.tsx - Client boundary cho tất cả context providers
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SessionProvider } from 'next-auth/react';
import { useState } from 'react';

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());

  return (
    <SessionProvider>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </SessionProvider>
  );
}

// app/layout.tsx - import và wrap
import { Providers } from './providers';

export default function RootLayout({ children }) {
  return (
    <html lang="vi">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

Metadata (title, description, Open Graph) không còn dùng next/head. Thay vào đó bạn export const metadata hoặc function generateMetadata từ mỗi page.tsx hoặc layout.tsx. Đây là một trong những cải tiến DX rõ nét nhất. Không còn cảnh gõ <Head> lung tung khắp components con.

getServerSideProps thay thế bằng gì trong App Router?

Đây là câu hỏi tôi nghe nhiều nhất khi tư vấn migration. Câu trả lời: getServerSideProps được thay bằng async Server Components. Bạn fetch data trực tiếp trong component, không cần export function riêng.

Code cũ trong Pages Router:

// pages/products/[id].tsx
import type { GetServerSideProps } from 'next';

type Props = { product: Product };

export default function ProductPage({ product }: Props) {
  return <h1>{product.name}</h1>;
}

export const getServerSideProps: GetServerSideProps<Props> = async ({ params }) => {
  const res = await fetch(`https://api.example.com/products/${params?.id}`);
  const product = await res.json();

  return { props: { product } };
};

Chuyển sang App Router:

// app/products/[id]/page.tsx - Server Component với async
type PageProps = {
  params: Promise<{ id: string }>;
};

export default async function ProductPage({ params }: PageProps) {
  // params là Promise trong Next.js 15+, bắt buộc await
  const { id } = await params;

  // fetch chạy trên server, không gửi API key xuống client
  const res = await fetch(`https://api.example.com/products/${id}`, {
    cache: 'no-store', // tương đương getServerSideProps: luôn fresh
  });
  const product: Product = await res.json();

  return <h1>{product.name}</h1>;
}

Ba khác biệt quan trọng cần nắm:

  1. params giờ là Promise: Đây là thay đổi Next.js 15+ để framework có thể stream static shell trước khi dynamic segments resolve. Quên await là lỗi phổ biến nhất khi upgrade (may mắn là TypeScript sẽ báo).
  2. Cache semantics khác: Trong Next.js 16 với Cache Components, mặc định fetch không cache. Bạn phải opt-in bằng { cache: 'force-cache' } hoặc dùng directive use cache trong Next.js 16.
  3. Không cần props drilling: Server Component có thể await trực tiếp, không cần return { props } rồi truyền xuống.

Với getStaticProps cộng getStaticPaths, mapping tương đương là function generateStaticParams:

// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts').then(r => r.json());
  return posts.map((post: { slug: string }) => ({ slug: post.slug }));
}

export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await fetch(`https://api.example.com/posts/${slug}`, {
    next: { revalidate: 3600 }, // ISR: revalidate mỗi giờ
  }).then(r => r.json());

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

Migrate next/router sang next/navigation

Trong App Router, hook useRouter được import từ next/navigation, không phải next/router. API cũng thay đổi đáng kể, nhiều property bị chia thành các hook riêng.

Bảng đối chiếu API:

next/router (Pages) next/navigation (App)
router.pathnameusePathname()
router.queryuseSearchParams()
router.push('/x')router.push('/x') (không đổi)
router.eventsBỏ, dùng usePathname + useEffect
router.asPathBỏ, không có tương đương trực tiếp
router.isReadyBỏ, Server Components luôn "ready"
router.localeBỏ, i18n built-in đã bị gỡ khỏi App Router

Ví dụ migrate một component search bar:

// Trước - Pages Router
import { useRouter } from 'next/router';

function SearchBar() {
  const router = useRouter();
  const query = router.query.q as string; // đọc query param
  const pathname = router.pathname;

  return (
    <button onClick={() => router.push(`/search?q=${query}`)}>
      Search from {pathname}
    </button>
  );
}

// Sau - App Router (bắt buộc 'use client')
'use client';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';

function SearchBar() {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const query = searchParams.get('q') ?? '';

  return (
    <button onClick={() => router.push(`/search?q=${query}`)}>
      Search from {pathname}
    </button>
  );
}

Quy tắc vàng: bất kỳ component nào import từ next/navigation đều phải là Client Component, thêm 'use client' ở đầu file. Nếu bạn chỉ cần đọc pathname/searchParams cho Server Component, dùng props searchParams được inject vào page.tsx thay vì hook.

Chuyển pages/api sang Route Handlers

API Routes cũ (pages/api/*.ts) được thay bằng Route Handlers (app/api/*/route.ts). Signature hoàn toàn khác. Không còn NextApiRequest/NextApiResponse, thay bằng Web standard Request/Response (theo Web Fetch API).

// Trước - pages/api/users/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method === 'GET') {
    const user = await db.user.findUnique({ where: { id: Number(req.query.id) } });
    return res.status(200).json(user);
  }
  res.setHeader('Allow', ['GET']);
  res.status(405).end();
}

// Sau - app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET(
  _req: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const user = await db.user.findUnique({ where: { id: Number(id) } });
  return NextResponse.json(user);
}

Route Handlers hỗ trợ đầy đủ HTTP methods qua named exports (GET, POST, PUT, DELETE, PATCH). Không cần if (req.method === '...') nữa. Mỗi method là một function riêng.

Với các endpoint chỉ dùng nội bộ (form submission, mutations), cân nhắc chuyển sang Server Actions thay vì Route Handlers. Tôi đã viết bài chi tiết so sánh Route Handlers với Server Actions giúp bạn quyết định chọn cái nào cho từng use case.

Coexistence: chạy Pages và App Router song song

Điểm mạnh nhất của cách tiếp cận incremental là pages/app/ có thể cùng tồn tại. Next.js sẽ ưu tiên app/ nếu cả hai đều có route trùng, cho phép bạn chuyển từng URL một mà không downtime.

my-app/
├── app/
│   ├── layout.tsx           # Root layout mới
│   ├── page.tsx             # Home page đã migrate
│   └── products/
│       └── [id]/
│           └── page.tsx     # /products/[id] đã migrate
└── pages/
    ├── _app.tsx             # Vẫn dùng cho các route chưa migrate
    ├── about.tsx            # /about vẫn chạy Pages Router
    ├── contact.tsx          # /contact vẫn chạy Pages Router
    └── api/
        └── legacy-webhook.ts # API cũ chưa cần chuyển

Chiến lược tôi thường áp dụng: migrate theo traffic ngược từ thấp lên cao. Bắt đầu với các route ít user (trang cài đặt, admin), tích lũy confidence, rồi mới đụng đến homepage và product pages. Nếu có bug regression, blast radius nhỏ.

Một thứ cần lưu ý: link giữa app/pages/ sẽ trigger full page navigation (hard reload), không phải client-side transition. Đó là lý do bạn muốn migrate các flow liên kết chặt (như checkout: cart, payment, confirmation) cùng một lượt thay vì rải rác.

Ước tính effort: mất bao lâu để migrate?

Đây là estimate tôi đưa cho stakeholder khi scoping migration, dựa trên 5 dự án thực tế đã làm 2024–2026:

Kích thước dự án Số routes Effort dev-days Rủi ro chính
Nhỏ < 20 3–5 ngày Ít, codemod xử lý được gần hết
Vừa 20–100 2–4 tuần Refactor data fetching, auth flows
Lớn 100–500 6–12 tuần Custom middleware, GraphQL loaders, third-party i18n
Enterprise > 500 3–6 tháng Coordination giữa nhiều team, feature freeze

Các activity chiếm nhiều thời gian nhất (từ retro của các team tôi làm việc cùng):

  1. Refactor data fetching (35–40% thời gian): Chuyển từ getServerSideProps sang Server Components cộng fetch() yêu cầu suy nghĩ lại về cache strategy.
  2. Auth và session management (15–20%): next-auth có bản App Router, nhưng middleware auth logic cần rewrite.
  3. Custom Webpack loaders (10–15%): Turbopack (mặc định trong Next 16) chưa support 100% Webpack loaders. Phải thay thế hoặc fallback --webpack.
  4. Testing (15%): Server Components khó test hơn, cần setup Jest/Vitest riêng cho async components.
  5. Documentation và training team (10%): Đừng bỏ qua. Mental model mới cần thời gian để thấm.

Để giảm downside risk, tôi luôn khuyến nghị team dùng streaming với Suspense và loading.tsx ngay từ những route đầu tiên migrate. Nó cải thiện TTFB rõ rệt và cho stakeholder thấy giá trị migration bằng số liệu Core Web Vitals cụ thể, không chỉ là "code sạch hơn".

Câu hỏi thường gặp

Có nên migrate từ Pages Router sang App Router năm 2026 không?

Nếu app của bạn đang chạy production ổn định và không cần feature mới của React 19 (Server Components, Server Actions, PPR), bạn có thể đợi thêm 6–12 tháng. Nhưng cho tất cả dự án greenfield hoặc app có roadmap dài hạn, migrate ngay. Vercel sẽ không thêm feature mới cho Pages Router nữa.

Codemod của Next.js có tự động chuyển hết code không?

Không hoàn toàn. Codemod chính thức đạt tỉ lệ thành công khoảng 90% cho các thay đổi cơ học (async params, đổi imports, đổi tên file). Data fetching logic, custom auth middleware, và Webpack loaders vẫn cần bạn refactor thủ công. Codemod cung cấp baseline đúng, phần còn lại là fix các runtime error.

Có phải migrate hết một lượt hay có thể làm dần?

Làm dần. Thư mục pages/app/ có thể tồn tại song song trong cùng dự án Next.js. Bạn chuyển từng route (hoặc từng cụm route liên quan) một, deploy lên production, verify, rồi chuyển tiếp. Không cần big-bang rewrite.

useRouter trong App Router import từ đâu?

Import từ next/navigation, không phải next/router. Component dùng hook này bắt buộc phải là Client Component (khai báo 'use client' ở đầu file). API cũng khác: router.pathname đổi thành usePathname(), router.query đổi thành useSearchParams().

Server Component có replace hết getServerSideProps không?

Có, với cách viết đơn giản hơn. Bạn khai báo async function Page()fetch() data trực tiếp bên trong, không cần export function riêng. Lưu ý cache semantics trong Next.js 16 khác: mặc định fetch không cache, phải opt-in với { cache: 'force-cache' } hoặc directive 'use cache'.

Jasmine Patel
Về Tác Giả Jasmine Patel

Web framework specialist comparing Next.js to everything else so you don't have to. Migrates teams off legacy stacks for fun.