App Router는 Next.js 13에서 도입된 app/ 디렉터리 기반 라우터로, 서버 컴포넌트·중첩 레이아웃(Nested Layouts)·스트리밍을 기본으로 한다. pages/ 기반의 Pages Router에서 옮길 때 바뀌는 생각을 정리한다.
구조와 데이터
- 레이아웃은 중첩되고 이동해도 유지된다.
_app·_document가 하던 일은 루트app/layout.tsx가 맡는다 - 데이터를 페이지 단위가 아니라 컴포넌트 단위로 가져온다. 서버에서 가져와 클라이언트 컴포넌트에 props로 넘기는 패턴은 그대로 쓸 수 있다
next/head→metadata내보내기·generateMetadata- 컴포넌트는 기본이 서버다. 상호작용이 필요한 경계에만
'use client'를 붙인다('use client'와 클라이언트 경계) (group)처럼 괄호 폴더는 URL(Uniform Resource Locator)에 영향 없이 라우트를 묶는다(route group)- 검색 파라미터: 서버 컴포넌트 페이지는
searchParamsprop, 클라이언트 컴포넌트는useSearchParams훅(CSR bailout) middleware는 Next.js 16부터proxy로 이름이 바뀌었다(Next.js 16 변경점)
API 대응표
| Pages Router | App Router |
|---|---|
getServerSideProps | 서버 컴포넌트 본문에서 await. req의 쿠키·헤더는 cookies()·headers() |
getStaticProps + revalidate | Cache Components 켬: 'use cache' + cacheLife() / 끔: fetch의 next.revalidate |
getStaticPaths | generateStaticParams(경로 문자열이 아니라 세그먼트 객체 배열을 반환) |
fallback: false | dynamicParams = false(목록 밖 경로는 404. Cache Components를 켜면 쓸 수 없다) |
pages/_app·_document | 루트 app/layout.tsx. Provider 체인은 'use client' 파일로 분리해 레이아웃에 끼운다 |
_document의 beforeInteractive 스크립트 | 루트 레이아웃의 next/script |
pages/404·_error | not-found.tsx(notFound() 호출 시)·error.tsx(예상 못 한 에러, 클라이언트 컴포넌트)·루트 레이아웃 에러용 global-error.tsx. 컴포넌트 단위 경계는 catchError(16.3부터 정식) |
pages/api/* | app/**/route.ts(Web Request·Response 기반 Route Handler) |
next/router의 useRouter | next/navigation의 useRouter·usePathname·useSearchParams·useParams |
router.query | 경로 파라미터는 useParams, 쿼리는 useSearchParams |
router.asPath·isFallback·locale·basePath | 제거됨. 필요하면 usePathname + useSearchParams로 조립 |
router.events | usePathname·useSearchParams를 의존성으로 둔 useEffect |
shallow: true 라우팅 | window.history.pushState·replaceState(Next 라우터와 동기화됨) |
흔한 함정
params·searchParams는 Promise다(Next.js 15부터, 16에서 동기 접근 제거). 페이지·generateMetadata에서await한다usePathname()은 실제 URL을 준다./product/ABC이지/product/[code]가 아니다. 라우트 패턴별로 분기하던 로깅·분석 코드는useParams로 패턴을 되살리거나 패턴 매칭을 따로 둔다pages/가 남아 있는 동안에는useSearchParams·usePathname이null을 돌려줄 수 있다(공존기 호환용). 공용 컴포넌트에서?? ''로 정규화해 둔다router.refresh()는router.reload()가 아니다. 전체 새로고침이 아니라 서버 컴포넌트를 다시 받아 합치고,useState·스크롤 같은 클라이언트 상태는 유지한다. 서버 캐시도 무효화하지 않는다- 서버 컴포넌트가
next/router를 간접 import하면 빌드가 깨진다. 트래커·유틸 모듈 하나가 라우터를 끌어오는 경우가 흔하니 import 경로를 나눈다 - Cache Components에서는 세그먼트 설정
dynamic·revalidate·fetchCache·dynamicParams를 쓰지 않는다.'use cache'·cacheLife로 대체하고, 요청 시점 API를 읽는 부분은 Suspense로 감싼다("use cache"와 Cache Components, CSR bailout) - 인증 데이터를 읽는 함수에
'use cache'를 붙이면 사용자끼리 결과를 공유하게 된다. 쿠키를 읽는 부분은 캐시 밖에 두고, 꼭 필요하면 추출한 값만 인자로 넘겨 캐시 키에 넣는다 - 공존기에는
_app·_document를 지우지 않는다. 루트 레이아웃의 스타일은pages/*에 적용되지 않으므로 다 옮긴 뒤 지운다
체감 성능이 달라지는 이유
- Pages Router는 전체 페이지 JS(JavaScript)를 받아 트리 전체를 한 번에 하이드레이션했다. 쓸 수 있는 시점이 하나였다
- App Router는 서버 컴포넌트 JS를 보내지 않고, 클라이언트 경계마다 따로 하이드레이션한다. 정적 셸과 캐시된 본문은 JS 실행 전에 그려져 LCP(Largest Contentful Paint)가 당겨지고, 하이드레이션할 JS가 줄어 상호작용 시점도 당겨진다(Core Web Vitals)
- 대신 "버튼은 이미 눌리는데 사용자 세션 데이터는 아직 없는" 구간이 실제로 생긴다. 로딩 중과 데이터 없음을 구분해 처리해야 한다
렌더링 방식 전체는 CSR·SSR·SSG·ISR, 캐시는 "use cache"와 Cache Components·Next.js 캐시 계층. 기존 시스템을 갈아엎을지 점진적으로 옮길지는 차세대와 고도화·병렬 수정 (팽창-수축) 관점에서 고민한다.
출처: Next.js - App Router Incremental Adoption Guide · Next.js - Linking and Navigating: Native History API · Next.js - useRouter