Pages Router vs App Router 핵심 차이, 단계별 마이그레이션 절차, getServerSideProps→Server Component 전환, API Routes→Route Handlers, layout/loading/error 패턴, 흔한 에러 해결.
한 줄 요약: Pages Router에서 App Router로의 마이그레이션은 점진적으로 가능하다. 두 라우터는 같은 Next.js 프로젝트 안에서 공존할 수 있으므로 페이지 단위로 전환하는 전략이 현실적이다.
이 글이 필요한 사람
Next.js Pages Router 프로젝트를 App Router로 전환하려는 개발자
App Router와 Pages Router의 개념 차이를 정확히 이해하고 싶은 경우
getServerSideProps, getStaticProps를 Server Component로 어떻게 바꾸는지 알고 싶은 경우
마이그레이션 중 흔히 만나는 에러와 해결 방법을 찾는 경우
Pages Router vs App Router — 핵심 개념 차이
App Router(Next.js 13.4에서 stable)는 React Server Components(RSC)를 기반으로 한다. Pages Router의 컴포넌트는 기본적으로 클라이언트 컴포넌트였지만, App Router에서는 기본이 서버 컴포넌트다. 이 전환이 모든 마이그레이션의 출발점이다.
항목
Pages Router
App Router
기본 컴포넌트
클라이언트 컴포넌트
서버 컴포넌트 (기본)
데이터 페칭
getServerSideProps, getStaticProps
서버 컴포넌트 내 async/await
레이아웃
_app.js, _document.js
layout.js (중첩 레이아웃)
API 라우트
pages/api/*.js
app/api/*/route.js
로딩 상태
수동 구현
loading.js (자동 Suspense)
에러 처리
수동 구현 또는 _error.js
error.js (Error Boundary)
메타데이터
next/head (컴포넌트 내)
metadata export 또는 generateMetadata
캐싱
revalidate 옵션
fetch 옵션 (force-cache, no-store, revalidate)
가장 중요한 차이는 서버 컴포넌트다. 서버 컴포넌트는 서버에서만 실행되고 클라이언트로 번들에 포함되지 않는다. useState, useEffect, 이벤트 핸들러는 서버 컴포넌트에서 사용할 수 없다. 이것이 마이그레이션에서 가장 많은 에러의 원인이다.
Next.js App Router 마이그레이션 완벽 가이드 — Pages Router에서 전환하기 — 프레임워크 성능 벤치마크 (출처: 공식 문서 및 벤치마크 데이터 기반)
마이그레이션 단계별 절차
Next.js 공식 마이그레이션 가이드(nextjs.org/docs/app/building-your-application/upgrading/app-router-migration)에서 권장하는 순서를 기반으로 실무에서 겪는 세부 사항을 보완했다.
1단계: Next.js 버전 업그레이드
App Router는 Next.js 13.4 이상에서 stable이다. 14.x 또는 15.x로 업그레이드하고 기존 Pages Router 기능이 정상 동작하는지 먼저 확인한다.
2단계: app 디렉터리 생성
프로젝트 루트에 app/ 디렉터리를 만든다. 이 시점부터 pages/와 app/이 공존한다. Next.js는 두 폴더를 모두 인식하며 라우팅도 각각 독립적으로 동작한다.
3단계: 루트 layout.js 작성
app/layout.js는 필수 파일이다. Pages Router의 _app.js와 _document.js를 합친 역할을 한다. 여기서 <html>, <body> 태그를 반환해야 한다.
4단계: 페이지 단위 점진적 전환
페이지 하나씩 pages/에서 app/으로 옮긴다. 옮긴 페이지는 pages/에서 삭제해야 충돌이 없다.
5단계: API Routes → Route Handlers 전환
마지막으로 pages/api/의 API 라우트를 app/api/의 Route Handlers로 이전한다.
루트 layout.js — _app.js + _document.js 통합
// app/layout.tsx
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
// 정적 메타데이터
export const metadata: Metadata = {
title: {
template: '%s | My App',
default: 'My App',
},
description: '앱 설명',
}
// _document.js의 <html>, <body> 역할
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang='ko'>
<body className={inter.className}>
{/* _app.js의 전역 Provider 등 */}
{children}
</body>
</html>
)
}
getServerSideProps와 getStaticProps를 서버 컴포넌트로 전환
App Router에서 데이터 페칭은 서버 컴포넌트 안에서 직접 async/await로 처리한다. getServerSideProps와 getStaticProps는 App Router에서 사용할 수 없다.
Next.js App Router 마이그레이션 완벽 가이드 — Pages Router에서 전환하기 — 컴포넌트 아키텍처 다이어그램 (출처: 공식 문서 및 벤치마크 데이터 기반)
getServerSideProps → async 서버 컴포넌트
// === Pages Router (이전) ===
// pages/users/[id].tsx
import { GetServerSideProps } from 'next'
interface Props { user: { id: string; name: string } }
export default function UserPage({ user }: Props) {
return <div>{user.name}</div>
}
export const getServerSideProps: GetServerSideProps = async ({ params }) => {
const res = await fetch('https://api.example.com/users/' + params!.id)
const user = await res.json()
return { props: { user } }
}
// === App Router (이후) ===
// app/users/[id]/page.tsx
async function getUser(id: string) {
const res = await fetch('https://api.example.com/users/' + id, {
cache: 'no-store', // getServerSideProps와 동일: 매 요청마다 새로 조회
// cache: 'force-cache' // getStaticProps와 동일: 캐시 사용
// next: { revalidate: 3600 } // ISR: 1시간마다 재검증
})
if (!res.ok) throw new Error('사용자를 불러오지 못했습니다')
return res.json()
}
// 페이지 자체가 async 서버 컴포넌트
export default async function UserPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params // Next.js 15: params는 Promise
const user = await getUser(id)
return <div>{user.name}</div>
}
// 동적 메타데이터 (getServerSideProps의 head 역할)
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const user = await getUser(id)
return { title: user.name + ' | My App' }
}
Pages Router의 pages/api/ 파일은 export default function handler(req, res) 패턴이었다. App Router의 Route Handlers는 HTTP 메서드별로 named export를 사용하며, Next.js의 Request/Response(Web API 기반)를 사용한다.
pages/api → app/api Route Handler 전환
// === Pages Router (이전) ===
// pages/api/users/[id].ts
import { NextApiRequest, NextApiResponse } from 'next'
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { id } = req.query
if (req.method === 'GET') {
const user = await getUserById(String(id))
return res.status(200).json(user)
}
if (req.method === 'PUT') {
const body = req.body
const updated = await updateUser(String(id), body)
return res.status(200).json(updated)
}
res.status(405).end()
}
// === App Router (이후) ===
// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
// HTTP 메서드별 named export
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const user = await getUserById(id)
return NextResponse.json(user)
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const body = await request.json()
const updated = await updateUser(id, body)
return NextResponse.json(updated)
}
// CORS 헤더 추가 예시
export async function OPTIONS() {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, PUT, DELETE',
},
})
}
layout.js, loading.js, error.js 패턴
App Router의 특수 파일들은 Pages Router에 없는 강력한 기능을 제공한다. 각 라우트 세그먼트마다 독립적으로 레이아웃, 로딩, 에러 처리를 정의할 수 있다.
Next.js App Router 마이그레이션 완벽 가이드 — Pages Router에서 전환하기 — 렌더링 파이프라인 비교 (출처: 공식 문서 및 벤치마크 데이터 기반)
loading.js, error.js, not-found.js 패턴
// app/dashboard/loading.tsx
// 이 파일만 만들면 dashboard 세그먼트 전체에 Suspense 자동 적용
export default function DashboardLoading() {
return (
<div className='loading-skeleton'>
<div className='skeleton-bar' />
<div className='skeleton-bar' />
</div>
)
}
// ---
// app/dashboard/error.tsx
// Error Boundary — 이 세그먼트의 에러를 캐치
'use client' // error.js는 반드시 클라이언트 컴포넌트여야 함
import { useEffect } from 'react'
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error(error)
}, [error])
return (
<div>
<h2>대시보드를 불러오지 못했습니다</h2>
<button onClick={reset}>다시 시도</button>
</div>
)
}
// ---
// app/not-found.tsx — 전역 404 페이지
export default function NotFound() {
return (
<div>
<h1>페이지를 찾을 수 없습니다</h1>
</div>
)
}
흔한 실수와 해결 방법
App Router 마이그레이션에서 반복적으로 만나는 에러와 해결 방법을 정리한다.
에러: "useState can only be used in a Client Component" 원인: App Router에서 컴포넌트는 기본이 서버 컴포넌트다. useState, useEffect, 이벤트 핸들러는 서버 컴포넌트에서 사용할 수 없다. 해결: 상태나 이벤트가 필요한 컴포넌트 파일 상단에 "use client" 지시어를 추가한다. 서버 컴포넌트와 클라이언트 컴포넌트를 분리해 클라이언트 번들을 최소화하는 것이 권장 패턴이다.
에러: "cookies() / headers() was called outside a request scope" 원인: next/headers의 cookies(), headers()는 서버 컴포넌트 또는 서버 액션, Route Handler에서만 호출 가능하다. 클라이언트 컴포넌트나 모듈 최상단에서 호출하면 에러가 발생한다. 해결: 서버 컴포넌트에서 값을 읽어 클라이언트 컴포넌트에 props로 전달하거나, 서버 액션으로 처리한다.
에러: 중복 라우트 충돌 (pages/ 와 app/ 동시 존재) 원인: pages/about.tsx와 app/about/page.tsx가 동시에 존재하면 충돌 경고가 발생한다. 해결: 하나의 페이지를 app/으로 옮겼다면 pages/ 에서 반드시 삭제한다. 점진적 마이그레이션 중에는 같은 경로를 두 폴더에 동시에 두지 않는다.
점진적 마이그레이션 권장 순서:
정적 페이지(데이터 페칭 없는 페이지)부터 전환 — 난이도 낮음
getStaticProps 페이지 전환 — fetch + force-cache 또는 revalidate로 교체
getServerSideProps 페이지 전환 — fetch + no-store로 교체
클라이언트 전용 기능이 많은 페이지 — use client 컴포넌트 분리 후 전환
API Routes 전환 — 마지막 단계, 기존 pages/api/는 app/ 전환 완료 후 삭제
Next.js는 공식적으로 두 라우터의 공존을 지원하므로 서비스 중단 없이 장기간에 걸쳐 마이그레이션을 진행할 수 있다.