Next.js App Router đã production-ready từ 13.4. Bài này hướng dẫn migrate từ Pages Router an toàn — file convention, data fetching, API route, metadata.
Pages vs App Router cùng tồn tại
Trong cùng project có thể có cả pages/ và app/. Next route ưu tiên app/ nếu trùng path. Cho phép migrate từng route — không phải big bang rewrite.
File convention mới
| Pages Router | App Router | Vai trò |
|---|---|---|
| pages/index.tsx | app/page.tsx | Route / |
| pages/blog/[slug].tsx | app/blog/[slug]/page.tsx | Dynamic route |
| pages/_app.tsx | app/layout.tsx | Wrapper toàn app |
| pages/api/users.ts | app/api/users/route.ts | API endpoint |
| (không có) | app/loading.tsx | Loading UI |
| pages/_error.tsx | app/error.tsx | Error boundary |
| pages/_document.tsx | app/layout.tsx (root) | HTML structure |
Root layout
// app/layout.tsx — replace _app.tsx + _document.tsx
import { ReactNode } from 'react'
import { ThemeProvider } from '@/components/theme-provider'
import './globals.css'
export const metadata = {
title: { default: 'Alodev', template: '%s | Alodev' },
description: 'Web/app development',
}
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="vi">
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
)
}
Data fetching: bỏ getServerSideProps
// ❌ Pages Router cũ
export async function getServerSideProps({ params }) {
const post = await fetch(`https://api/posts/${params.slug}`).then(r => r.json())
return { props: { post } }
}
export default function Post({ post }) { return <h1>{post.title}</h1> }
// ✓ App Router — async server component
export default async function Post({ params }) {
const post = await fetch(`https://api/posts/${params.slug}`, {
next: { revalidate: 60 } // ISR-style cache
}).then(r => r.json())
return <h1>{post.title}</h1>
}
Cache options:
{ cache: 'force-cache' }— static, default{ cache: 'no-store' }— dynamic, mỗi request{ next: { revalidate: 60 } }— ISR 60s{ next: { tags: ['posts'] } }— tag để revalidateTag
generateStaticParams
// ❌ Cũ: getStaticPaths
export async function getStaticPaths() {
const posts = await db.posts.findAll()
return { paths: posts.map(p => ({ params: { slug: p.slug } })), fallback: false }
}
// ✓ Mới
export async function generateStaticParams() {
const posts = await db.posts.findAll()
return posts.map(p => ({ slug: p.slug }))
}
export default async function Page({ params }) {
const post = await db.posts.findBySlug(params.slug)
return <article>{post.content}</article>
}
Metadata API thay Head
// ❌ Cũ: next/head
import Head from 'next/head'
export default function Page() {
return (<>
<Head>
<title>Blog</title>
<meta name="description" content="..." />
</Head>
<h1>Blog</h1>
</>)
}
// ✓ Mới: declarative metadata
export const metadata = {
title: 'Blog',
description: 'Bài viết về web/app',
openGraph: { title: 'Blog', images: ['/og.png'] },
}
export default function Page() { return <h1>Blog</h1> }
// Hoặc dynamic
export async function generateMetadata({ params }) {
const post = await db.posts.findBySlug(params.slug)
return {
title: post.title,
description: post.excerpt,
openGraph: { images: [post.cover] },
}
}
API Route Handler
// ❌ Cũ: pages/api/users.ts
export default function handler(req, res) {
if (req.method === 'POST') { /* ... */ }
}
// ✓ Mới: app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(req: NextRequest) {
const users = await db.users.findAll()
return NextResponse.json(users)
}
export async function POST(req: NextRequest) {
const body = await req.json()
const user = await db.users.create(body)
return NextResponse.json(user, { status: 201 })
}
Loading + Error UI
// app/blog/loading.tsx — auto Suspense fallback
export default function Loading() {
return <div className="skeleton">Loading posts...</div>
}
// app/blog/error.tsx — auto error boundary
'use client'
export default function Error({ error, reset }) {
return (
<div>
<h2>Có lỗi: {error.message}</h2>
<button onClick={reset}>Thử lại</button>
</div>
)
}
Convention thay code — không cần wrap manual Suspense/ErrorBoundary.
Plan migrate từng bước
- Tuần 1: Setup app/ folder, tạo root layout cơ bản. Pages vẫn chạy.
- Tuần 2: Migrate 1 route ít quan trọng (about, terms). Test kỹ.
- Tuần 3-4: Migrate route content (blog, docs) — server component thắng nhiều.
- Tuần 5+: Migrate route phức tạp (dashboard, auth) — cẩn thận với client component, hooks library.
- Cuối: Migrate API routes (cùng path khác file).
- Done: Xoá
pages/khi không còn route nào.
Kết luận
Next.js App Router migration không quá đáng sợ — convention rõ, từng bước. Lợi ích: bundle nhỏ hơn, server fetch trực tiếp, streaming UX tốt. Đầu tư 2-4 tuần migrate cho project < 50 route là hợp lý. Đọc React Server Components để hiểu sâu paradigm mới.