TechFeedTechFeed
Frontend

Next.js App Router 마이그레이션 완벽 가이드 — Pages Router에서 전환하기

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 RouterApp Router
기본 컴포넌트클라이언트 컴포넌트서버 컴포넌트 (기본)
데이터 페칭getServerSideProps, getStaticProps서버 컴포넌트 내 async/await
레이아웃_app.js, _document.jslayout.js (중첩 레이아웃)
API 라우트pages/api/*.jsapp/api/*/route.js
로딩 상태수동 구현loading.js (자동 Suspense)
에러 처리수동 구현 또는 _error.jserror.js (Error Boundary)
메타데이터next/head (컴포넌트 내)metadata export 또는 generateMetadata
캐싱revalidate 옵션fetch 옵션 (force-cache, no-store, revalidate)

가장 중요한 차이는 서버 컴포넌트다. 서버 컴포넌트는 서버에서만 실행되고 클라이언트로 번들에 포함되지 않는다. useState, useEffect, 이벤트 핸들러는 서버 컴포넌트에서 사용할 수 없다. 이것이 마이그레이션에서 가장 많은 에러의 원인이다.

마이그레이션 단계별 절차

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로 처리한다. getServerSidePropsgetStaticProps는 App 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' } }
getStaticPaths → generateStaticParams
// === Pages Router (이전) === // pages/posts/[slug].tsx export async function getStaticPaths() { const posts = await fetch('https://api.example.com/posts').then(r => r.json()) return { paths: posts.map((p: any) => ({ params: { slug: p.slug } })), fallback: 'blocking', } } // === App Router (이후) === // app/posts/[slug]/page.tsx export async function generateStaticParams() { const posts = await fetch('https://api.example.com/posts').then(r => r.json()) // 단순화됨: paths 래퍼 없이 params 배열 직접 반환 return posts.map((post: { slug: string }) => ({ slug: post.slug })) } export default async function PostPage({ params, }: { params: Promise<{ slug: string }> }) { const { slug } = await params const post = await fetch('https://api.example.com/posts/' + slug, { next: { revalidate: 3600 }, }).then(r => r.json()) return <article>{post.content}</article> }

API Routes → Route Handlers 전환

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에 없는 강력한 기능을 제공한다. 각 라우트 세그먼트마다 독립적으로 레이아웃, 로딩, 에러 처리를 정의할 수 있다.

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/ 에서 반드시 삭제한다. 점진적 마이그레이션 중에는 같은 경로를 두 폴더에 동시에 두지 않는다.

점진적 마이그레이션 권장 순서:

  1. 정적 페이지(데이터 페칭 없는 페이지)부터 전환 — 난이도 낮음
  2. getStaticProps 페이지 전환 — fetch + force-cache 또는 revalidate로 교체
  3. getServerSideProps 페이지 전환 — fetch + no-store로 교체
  4. 클라이언트 전용 기능이 많은 페이지 — use client 컴포넌트 분리 후 전환
  5. API Routes 전환 — 마지막 단계, 기존 pages/api/는 app/ 전환 완료 후 삭제

Next.js는 공식적으로 두 라우터의 공존을 지원하므로 서비스 중단 없이 장기간에 걸쳐 마이그레이션을 진행할 수 있다.

Next.jsApp Router마이그레이션서버컴포넌트Route HandlerReact

관련 포스트

Next.js 15 핵심 변경사항 총정리2026-02-15Svelte 5 vs React 19 — 2026년 프론트엔드 프레임워크 비교2026-03-13Remix vs Next.js — 풀스택 프레임워크 비교 20262026-03-14React Server Components 실전 가이드2026-02-17