อัปเดตล่าสุด: 19 กันยายน 2026
การ Migrate จาก Pages Router ไป App Router ใน Next.js 16 เป็นงานที่ทำแบบค่อยเป็นค่อยไปได้ (incremental) โดยรันสองระบบพร้อมกัน แล้วย้ายทีละ route จากประสบการณ์ที่ผมย้ายทีมมาสาม-สี่ทีมในปี 2026 โปรเจกต์เล็ก (น้อยกว่า 30 routes) ใช้เวลาประมาณ 5–8 วันทำการ ส่วนโปรเจกต์กลาง (30–100 routes ที่มี getServerSideProps เยอะ) อยู่ที่ 15–25 วัน และโปรเจกต์ใหญ่ที่ผูกกับ Redux/SWR ทั่วทั้ง codebase อาจใช้ 40+ วัน คู่มือนี้จะพาคุณเดินตามขั้นตอนจริงตั้งแต่ audit, mapping, ย้ายทีละ route, ไปจนถึงการลบ Pages Router ทิ้ง
Next.js 16 ยังรองรับทั้ง pages/ และ app/ พร้อมกันในโปรเจกต์เดียว จึงย้ายทีละ route ได้โดยไม่ต้อง big bang
getServerSideProps และ getStaticProps ถูกแทนที่ด้วย async function Page() ใน Server Components (ไม่ต้อง fetch จาก client อีกต่อไป)
_app.tsx/_document.tsx รวมเข้าเป็น app/layout.tsx เดียว ส่วน providers ต้องมี "use client"
useRouter ย้ายจาก next/router ไป next/navigation พร้อมเปลี่ยน API (ไม่มี router.query, ใช้ useSearchParams/useParams แทน)
pages/api/* กลายเป็น app/<path>/route.ts ที่ export ฟังก์ชันตาม HTTP method (GET, POST, ...)
แผนงานที่แนะนำคือ audit ก่อน, ทำ shared layout, ตามด้วย routes ที่เป็น static, แล้วค่อย SSR, API routes สุดท้ายลบ pages/
สารบัญ
ทำไมต้อง Migrate จาก Pages Router?
Pages Router vs App Router: ตารางเปรียบเทียบ
ประมาณเวลาเป็นวัน: จะใช้เวลาเท่าไหร่?
ขั้นที่ 1: Audit โปรเจกต์เดิมและตั้ง app/
ขั้นที่ 2: ย้าย _app และ _document ไปเป็น layout.tsx
ขั้นที่ 3: แปลง getServerSideProps และ getStaticProps
ขั้นที่ 4: แก้ไข useRouter, Link และ Dynamic Routes
ขั้นที่ 5: ย้าย pages/api ไปเป็น Route Handlers
ขั้นที่ 6: Middleware และ Metadata
ปัญหาที่พบบ่อยและวิธีแก้
คำถามที่พบบ่อย
ทำไมต้อง Migrate จาก Pages Router?
ในทีมที่ผมช่วย migrate เมื่อไตรมาสที่แล้ว มีคนถามเสมอว่า "ทำไมต้องย้าย ในเมื่อ Pages Router ก็ยังใช้งานได้" คำตอบสั้น ๆ คือ App Router เปิดให้เข้าถึงฟีเจอร์ที่ Pages Router ไม่มีทางมี ได้แก่ React Server Components ที่ตัด JavaScript bundle ฝั่ง client ลงเฉลี่ย 30–50% ตามที่วัดจริงในโปรเจกต์ e-commerce (ผมเจอตัวเลขแบบนี้จริงในแอปตัวหนึ่งที่ทำเสร็จเดือนที่แล้ว), Streaming ด้วย <Suspense> ที่ทำให้ TTFB ลดลงเพราะไม่ต้องรอ data ครบทุกก้อนก่อน render, Partial Prerendering (PPR) ที่รวม static shell กับ dynamic hole ไว้ในหน้าเดียว, Server Actions ที่ยกเลิกความจำเป็นของ API endpoints สำหรับ mutations, และ nested layouts ที่ persist ระหว่าง navigation โดยไม่ต้อง remount
อีกเหตุผลที่มองข้ามไม่ได้คือทิศทางของ Next.js ทีม Vercel ระบุชัดว่า Pages Router ยังได้รับการ maintain แต่ฟีเจอร์ใหม่ทุกตัวเปิดตัวใน App Router ก่อน และเอกสารทางการก็ default มาที่ App Router แล้วตั้งแต่ปี 2024 การอยู่กับ Pages Router ต่อจึงเป็นการยืดหนี้ทางเทคนิค (tech debt) ไปเรื่อย ๆ ยิ่งย้ายเร็วเท่าไร ต้นทุนก็ยิ่งต่ำ เพราะ codebase ยังเล็กและทีมยังจำ context ได้
Pages Router vs App Router: ตารางเปรียบเทียบ
ก่อนย้าย ให้ทำ mental model ก่อนว่าอะไรตรงกับอะไร ตารางด้านล่างสรุปการ mapping ที่ผมใช้เป็น cheat sheet ทุกครั้งที่เริ่มโปรเจกต์ migration ใหม่ ควรพิมพ์ออกมาแปะข้างจอไว้ใช้ตลอด
สิ่งที่ต้องย้าย Pages Router App Router (Next.js 16)
โฟลเดอร์หลัก pages/app/
หน้า index pages/index.tsxapp/page.tsx
Layout ระดับ root pages/_app.tsx + _document.tsxapp/layout.tsx เดียว
Data fetching (SSR) getServerSidePropsasync function Page() + fetch()
Data fetching (SSG) getStaticPropsasync function Page() + cache
Dynamic routes pages/blog/[slug].tsxapp/blog/[slug]/page.tsx
API endpoints pages/api/users.tsapp/api/users/route.ts
Client routing hook useRouter จาก next/routeruseRouter จาก next/navigation
Head tags <Head> จาก next/headexport const metadata หรือ generateMetadata()
Loading state เขียนเองใน component loading.tsx + <Suspense>
Error boundary เขียนเองใน component error.tsx + global-error.tsx
ประมาณเวลาเป็นวัน: จะใช้เวลาเท่าไหร่?
คำถามแรกที่ PM ถามผมเสมอคือ "ใช้เวลากี่วัน" ตัวเลขจริงที่ผมเก็บมาจากโปรเจกต์ที่ทำเสร็จแล้วในปี 2025–2026 มีดังนี้ สมมติว่าทีมมี dev 1–2 คนที่เข้าใจ Next.js อยู่แล้ว
โปรเจกต์เล็ก (10–30 routes, static เป็นหลัก, ไม่ค่อยมี state ซับซ้อน): 5–8 วันทำการ วันที่ 1–2 ตั้งโครง app/ และย้าย layout วันที่ 3–5 ย้าย routes ทีละหน้า วันที่ 6–7 ย้าย API วันที่ 8 QA และลบ pages/
โปรเจกต์กลาง (30–100 routes, มี SSR เยอะ, ใช้ SWR/React Query): 15–25 วันทำการ ส่วนใหญ่หมดไปกับการแปลง data fetching และหา race condition ที่เกิดจากการ mix client/server components
โปรเจกต์ใหญ่ (100+ routes, ผูก Redux/MobX ทั่วทั้งแอป, มี custom server): 40–80 วันทำการ ต้องแบ่ง phase ชัด และควรมี feature flag เปิด/ปิด
เคล็ดลับ: ถ้าประเมินไม่ได้ ให้ลอง migrate 3 routes แรก (route ที่ static, route ที่ SSR, route ที่มี API) แล้วจับเวลาจริง คูณด้วยจำนวน route แล้วบวก 30% buffer ตัวเลขที่ได้จะแม่นกว่าการเดา
ขั้นที่ 1: Audit โปรเจกต์เดิมและตั้ง app/
ก่อนแตะโค้ดใด ๆ ให้ทำ inventory ก่อน สิ่งที่ต้องรู้: มี routes ทั้งหมดกี่หน้า, กี่หน้าที่ใช้ getServerSideProps, กี่หน้าที่ใช้ getStaticProps, มี dynamic routes แบบไหน, และ dependencies ตัวไหนที่ผูกกับ next/router โดยตรง คำสั่งด้านล่างช่วยให้ audit ได้ในไม่กี่วินาที (ผมใช้ชุดนี้เป็นประจำก่อนเริ่มทุกโปรเจกต์)
# นับ routes ทั้งหมด
find pages -name "*.tsx" -not -name "_*" -not -path "*/api/*" | wc -l
# หาไฟล์ที่ใช้ getServerSideProps
grep -rn "getServerSideProps" pages/ --include="*.tsx"
# หาไฟล์ที่ import next/router
grep -rn "from 'next/router'" . --include="*.tsx"
# หา API routes
find pages/api -name "*.ts" | wc -l
เมื่อรู้ scope แล้ว ให้อัปเกรด Next.js เป็น 16 ก่อน (ยังไม่ต้องย้าย router):
npm install next@latest react@latest react-dom@latest
npx @next/codemod@latest upgrade latest
Codemod ทางการจาก Vercel จะจัดการเรื่อง breaking changes ระดับ syntax ให้อัตโนมัติ เมื่อ build ผ่านและ test เดิมทั้งหมด green แล้ว ค่อยสร้างโฟลเดอร์ app/ ข้าง ๆ pages/ Next.js 16 จะรันทั้งสอง router พร้อมกัน โดย app/ ชนะเสมอถ้ามี path เดียวกัน อ่านรายละเอียดเชิงลึกที่ Next.js App Router Migration Guide ทางการ ส่วนพื้นฐานของ Server Components เอง ผมแนะนำให้อ่าน React Server Components RFC เพิ่มเพื่อเข้าใจ mental model ก่อนลงมือ
ขั้นที่ 2: ย้าย _app และ _document ไปเป็น layout.tsx
ใน Pages Router เรามีสองไฟล์ระดับ root คือ _document.tsx ที่ควบคุม <html>/<body> และ _app.tsx ที่ครอบ providers และ global CSS ใน App Router รวมทุกอย่างมาอยู่ที่ app/layout.tsx เดียว โครงที่ผมใช้ทุกโปรเจกต์เป็นแบบนี้
// app/layout.tsx
import type { Metadata } from 'next'
import './globals.css'
import { Providers } from './providers'
export const metadata: Metadata = {
title: { default: 'My App', template: '%s | My App' },
description: 'Migrated from Pages Router',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="th">
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
ข้อสำคัญ: layout.tsx เป็น Server Component โดย default หมายความว่าใส่ <ThemeProvider>, <QueryClientProvider> หรือ context ใด ๆ ที่ใช้ hooks ตรง ๆ ไม่ได้ ต้องแยกเป็นไฟล์ providers.tsx ที่มี "use client" ที่บรรทัดแรก แล้ว import กลับมา
// app/providers.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ThemeProvider } from 'next-themes'
import { useState } from 'react'
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient())
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider attribute="class">{children}</ThemeProvider>
</QueryClientProvider>
)
}
Warning: ห้ามใช้ getInitialProps ใน _app.tsx ต่อ ถ้ามี ให้ย้าย logic ไปเป็น fetch() ใน Server Component หรือ Route Handler ไม่มีทาง port getInitialProps ตรง ๆ มาที่ App Router ได้
ขั้นที่ 3: แปลง getServerSideProps และ getStaticProps
นี่คือส่วนที่ทำให้ทีมส่วนใหญ่ "หายไปสัปดาห์นึง" แต่จริง ๆ แล้วง่ายกว่าที่คิด เพราะ Server Components ทำให้ data fetching เป็นเรื่องธรรมชาติ ไม่มี boilerplate เลย (ผมเคยเจอทีมที่เขียน HOC ห่อ getServerSideProps เป็นชั้น ๆ พอย้ายเสร็จแล้วโค้ดลดลงเกือบครึ่ง)
ตัวอย่าง getServerSideProps → Server Component
โค้ดเดิมใน Pages Router:
// pages/products/[id].tsx (แบบเก่า)
import type { GetServerSideProps } from 'next'
type Props = { product: Product }
export const getServerSideProps: GetServerSideProps<Props> = async ({ params }) => {
const res = await fetch(`https://api.example.com/products/${params!.id}`)
if (!res.ok) return { notFound: true }
const product = await res.json()
return { props: { product } }
}
export default function ProductPage({ product }: Props) {
return <h1>{product.name}</h1>
}
เขียนใหม่ใน App Router:
// app/products/[id]/page.tsx (แบบใหม่)
import { notFound } from 'next/navigation'
type PageProps = { params: Promise<{ id: string }> }
export default async function ProductPage({ params }: PageProps) {
const { id } = await params
const res = await fetch(`https://api.example.com/products/${id}`, {
cache: 'no-store', // เทียบเท่า getServerSideProps
})
if (!res.ok) notFound()
const product = await res.json()
return <h1>{product.name}</h1>
}
ตัวอย่าง getStaticProps → cache
สำหรับ SSG ใช้ cache: 'force-cache' (default) หรือ opt-in ที่ use cache ถ้าอยากใช้ Cache Components แบบละเอียด อ่านเพิ่มได้ในคู่มือแยก
// 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((p: { slug: string }) => ({ slug: p.slug }))
}
export const revalidate = 3600 // ISR: rebuild ทุก 1 ชั่วโมง
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await fetch(`https://api.example.com/posts/${slug}`).then((r) => r.json())
return <article>{post.title}</article>
}
ข้อดีที่จับต้องได้: fetch หลาย ๆ ครั้งที่ URL เดียวกันในระหว่าง render จะถูก dedupe อัตโนมัติผ่าน React cache ไม่ต้องเขียน DataLoader เอง
ขั้นที่ 4: แก้ไข useRouter, Link และ Dynamic Routes
คำถามที่คน migrate ถามบ่อยที่สุดคือ "useRouter ยังใช้ได้ไหม" คำตอบคือใช้ได้ แต่ต้อง import จาก next/navigation แทน next/router และ API ต่างกันอย่างมีนัยสำคัญ ที่สำคัญที่สุดคือ ไม่มี router.query ต้องแยกเป็นสอง hook คือ useParams (สำหรับ dynamic segments) และ useSearchParams (สำหรับ query string)
// เก่า (Pages Router)
'use client'
import { useRouter } from 'next/router'
export function OldClient() {
const router = useRouter()
const { id, tab } = router.query // id คือ dynamic, tab คือ query
return <button onClick={() => router.push('/home')}>กลับ</button>
}
// ใหม่ (App Router)
'use client'
import { useRouter, useParams, useSearchParams } from 'next/navigation'
export function NewClient() {
const router = useRouter()
const { id } = useParams<{ id: string }>() // dynamic segment
const tab = useSearchParams().get('tab') // query string
return <button onClick={() => router.push('/home')}>กลับ</button>
}
ส่วน <Link> จาก next/link ใช้ได้ทั้งสอง router แต่ใน App Router ไม่ต้องใส่ <a> ข้างในแล้ว และไม่มี prop legacyBehavior อีกต่อไปตั้งแต่ Next.js 16 ถ้ามีโค้ดเก่าที่เขียน <Link href="/x"><a>ข้อความ</a></Link> ให้เอา <a> ออกทันที
ขั้นที่ 5: ย้าย pages/api ไปเป็น Route Handlers
API routes เป็นส่วนที่ย้ายง่ายที่สุด structural mapping ตรงไปตรงมา: pages/api/users.ts เปลี่ยนเป็น app/api/users/route.ts แต่ signature ของ handler เปลี่ยนจาก (req, res) => void เป็น (request: Request) => Response ตาม Web standard สำหรับรายละเอียดเชิงลึกให้ดู คู่มือ Route Handlers ฉบับสมบูรณ์ ที่ครอบคลุมทุก HTTP method
// เก่า: pages/api/users.ts
import type { NextApiRequest, NextApiResponse } from 'next'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
const users = await db.users.findMany()
return res.status(200).json(users)
}
if (req.method === 'POST') {
const user = await db.users.create({ data: req.body })
return res.status(201).json(user)
}
res.status(405).end()
}
// ใหม่: app/api/users/route.ts
import { NextResponse } from 'next/server'
export async function GET() {
const users = await db.users.findMany()
return NextResponse.json(users)
}
export async function POST(request: Request) {
const body = await request.json()
const user = await db.users.create({ data: body })
return NextResponse.json(user, { status: 201 })
}
ระหว่าง migrate ถ้ามีทั้ง pages/api/users.ts และ app/api/users/route.ts พร้อมกัน Next.js จะโยน build error ตั้งใจ ให้ลบตัวเก่าก่อนสร้างตัวใหม่ (ผมเคยเจอบั๊กนี้ครั้งแรกตอน migrate ลูกค้าคนหนึ่ง เสียเวลา 20 นาทีกว่าจะรู้ว่าไฟล์ซ้อนกัน)
Note: ถ้ามี integration ที่เรียก API เดิม (mobile app, webhook, etc.) ที่คุณเปลี่ยน contract ไม่ได้ ให้เก็บ pages/api/* ไว้ก่อน แล้วย้าย only ตัวที่ frontend ใช้ ค่อยย้าย public API เป็น phase สุดท้ายพร้อมประกาศ deprecation
Middleware ใน Next.js 16 เปลี่ยนชื่อไฟล์จาก middleware.ts เป็น proxy.ts ที่ระดับ root ของโปรเจกต์ signature ยังเหมือนเดิม แต่ทาง Vercel แนะนำให้ใช้ชื่อใหม่เพื่อสะท้อนพฤติกรรมจริง (มัน proxy request ก่อนเข้า route) ผมเขียนรายละเอียดครบทุก use case ไว้ที่ คู่มือ Middleware Next.js 16
// proxy.ts (root level)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
const token = request.cookies.get('auth-token')
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*'],
}
ส่วน Metadata : ลบ <Head> จาก next/head ออกทั้งหมด แล้ว export metadata object หรือ generateMetadata() async function ที่ระดับ page/layout วิธีนี้ Next.js จะ generate <title>, meta og, twitter card, canonical และ JSON-LD ให้อัตโนมัติ
// app/products/[id]/page.tsx
import type { Metadata } from 'next'
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>
}): Promise<Metadata> {
const { id } = await params
const product = await fetch(`https://api.example.com/products/${id}`).then((r) => r.json())
return {
title: product.name,
description: product.summary,
openGraph: { images: [product.image] },
}
}
สำหรับ <Suspense>-based loading UI ให้ดู Streaming + Suspense ใน Next.js 16
ปัญหาที่พบบ่อยและวิธีแก้
จากที่ผมช่วยทีมต่าง ๆ debug ปัญหาห้าอันดับที่เจอซ้ำที่สุดคือ
"useState is not defined" ใน component ที่ย้ายมา: เกิดจากลืมใส่ "use client" ที่บรรทัดแรก Server Components ใช้ hooks ไม่ได้ แก้โดยเพิ่ม directive หรือ refactor ให้ไม่ใช้ state (บ่อยครั้งย้ายไปเป็น URL state ผ่าน searchParams ได้)
Hydration mismatch: เกิดจาก Date.now(), Math.random(), หรือ window อยู่ใน Server Component แก้โดยแยกส่วนที่ต้องรัน client ออกไปเป็น component ย่อยที่มี "use client"
Redux context เข้าถึงไม่ได้จาก Server Components: ถูกต้องแล้ว! store จะทำงานได้เฉพาะฝั่ง client โอกาสนี้เป็นโอกาสดีที่จะ evaluate ว่ายังต้องการ global state library หรือย้ายไปใช้ Server Components + Server Actions ได้เลย
Build ช้าลง: Server Components รวม dependencies ที่ใหญ่ (เช่น date library, markdown parser) แก้โดยกำหนด dynamic imports หรือย้ายไปทำใน API layer
Type ของ params/searchParams ผิด: ใน Next.js 15+ ทั้งสองเป็น Promise ต้อง await ก่อนใช้เสมอ ทีมส่วนใหญ่ที่ upgrade จาก Next.js 13/14 พลาดตรงนี้ (ผมเจอบั๊กนี้ตอนช่วยทีมนึง production พังเงียบเป็นชั่วโมง)
คำถามที่พบบ่อย
Migrate จาก Pages Router ไป App Router ใช้เวลานานแค่ไหน?
โปรเจกต์เล็ก (น้อยกว่า 30 routes) ใช้ประมาณ 5–8 วันทำการ กลาง 15–25 วัน และใหญ่ 40–80 วัน วิธีประมาณที่แม่นสุดคือ migrate 3 routes ตัวแทน (static, SSR, API) จับเวลาจริง แล้วคูณด้วยจำนวน route ที่เหลือ + buffer 30%
ใช้ pages/ กับ app/ พร้อมกันได้ไหม?
ได้ครับ Next.js 16 รองรับทั้งสอง router ในโปรเจกต์เดียวและออกแบบมาสำหรับการ migrate ทีละ route โดยเฉพาะ ถ้ามี path ซ้ำกัน App Router (app/) จะชนะเสมอ
getServerSideProps ยังใช้ได้ในโค้ดใหม่ไหม?
ใช้ได้เฉพาะในไฟล์ที่อยู่ใต้ pages/ เท่านั้น ใน App Router ต้องเปลี่ยนไปใช้ async function Page() พร้อม fetch() + { cache: 'no-store' } ที่ทำหน้าที่เดียวกันแต่เขียนน้อยกว่า
Redux/Zustand ใช้ได้กับ App Router ไหม?
ใช้ได้ แต่ต้องห่อ Provider ในไฟล์ที่มี "use client" เท่านั้น และ store จะเข้าถึงได้เฉพาะจาก Client Components คำแนะนำคือลองใช้ Server Components + Server Actions ก่อน หลาย ๆ ครั้งไม่จำเป็นต้องมี global state store อีก
ต้องเปลี่ยน useRouter ทุกที่ที่ใช้เลยไหม?
ต้องเปลี่ยน import path จาก next/router เป็น next/navigation และ refactor การอ่าน query โดยแทน router.query.foo ด้วย useParams() (dynamic) หรือ useSearchParams().get('foo') (query string) โปรเจกต์ทั่วไปมีจุดที่ต้องแก้ราว 20–40 ที่
ควรลบ pages/ ทิ้งเมื่อไหร่?
เมื่อทุก route ถูกย้ายและ smoke test ผ่านหมดแล้ว รอ 1–2 sprint เพื่อให้แน่ใจว่าไม่มี regression ก่อน commit ที่ลบ pages/ ควรแยกเป็น PR เดียวเพื่อ rollback ง่ายถ้าเจอปัญหาใน production