노트

Pages Router에서 App Router로

Migrating from Pages Router to App Router

프런트엔드#nextjs · 연결된 개념 16개

쉽게 말하면

Pages Router에서 App Router로 옮기는 건 방 구조가 다른 집으로 이사하는 거예요. 화면은 기본으로 서버에서 만들고 눌러야 하는 부분만 브라우저로 보내서, 첫 화면이 빨리 뜨고 빨리 눌려요.

비유가 깨지는 곳 가구를 그대로 옮기면 안 맞는 게 많아요. params가 Promise가 되고, router.refresh는 전체 새로고침이 아니며, 버튼은 눌리는데 세션 데이터는 아직 없는 구간도 따로 처리해야 해요.

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)
  • 검색 파라미터: 서버 컴포넌트 페이지는 searchParams prop, 클라이언트 컴포넌트는 useSearchParams 훅(CSR bailout)
  • middleware는 Next.js 16부터 proxy로 이름이 바뀌었다(Next.js 16 변경점)

API 대응표

Pages RouterApp Router
getServerSideProps서버 컴포넌트 본문에서 await. req의 쿠키·헤더는 cookies()·headers()
getStaticProps + revalidateCache Components 켬: 'use cache' + cacheLife() / 끔: fetch의 next.revalidate
getStaticPathsgenerateStaticParams(경로 문자열이 아니라 세그먼트 객체 배열을 반환)
fallback: falsedynamicParams = false(목록 밖 경로는 404. Cache Components를 켜면 쓸 수 없다)
pages/_app·_document루트 app/layout.tsx. Provider 체인은 'use client' 파일로 분리해 레이아웃에 끼운다
_document의 beforeInteractive 스크립트루트 레이아웃의 next/script
pages/404·_errornot-found.tsx(notFound() 호출 시)·error.tsx(예상 못 한 에러, 클라이언트 컴포넌트)·루트 레이아웃 에러용 global-error.tsx. 컴포넌트 단위 경계는 catchError(16.3부터 정식)
pages/api/*app/**/route.ts(Web Request·Response 기반 Route Handler)
next/router의 useRouternext/navigation의 useRouter·usePathname·useSearchParams·useParams
router.query경로 파라미터는 useParams, 쿼리는 useSearchParams
router.asPath·isFallback·locale·basePath제거됨. 필요하면 usePathname + useSearchParams로 조립
router.eventsusePathname·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

연결된 개념

이 노트를 가리키는 문서

뜻이 가까운 노트

  • loading 경계와 레이아웃 끌어올리기

    Next.js App Router에서 loading.tsx는 같은 세그먼트의 page와 그 아래(하위 layout 포함)를 suspense로 감싸지만, **같은 세그먼트의 layout은 감싸지 않는다**. 그래서 데이터를 기다릴 필요가 없는 공통 UI(탭바, 필터 헤더)를 page에서 layout으로 끌어올리면 스켈레톤에 덮이지 않고 처음부터 실제 모습으로 보인다.

  • Activity와 숨겨진 라우트 보존

    Activity는 React 19.2의 컴포넌트로, UI를 언마운트하지 않고 display: none으로 숨기면서 state와 DOM(Document Object Model)을 보존한다. 숨기는 동안 Effect는 정리(cleanup)된다. 예전 이름은 Offscreen이다.

  • 부분 사전 렌더링(PPR)

    PPR(Partial Prerendering)은 한 라우트를 빌드 때 만든 정적 셸(Static Shell)과 요청 때 채우는 동적 구멍으로 나눠, 셸은 즉시 보내고 구멍은 스트리밍으로 채우는 렌더링 모델이다. Next.js 16에서 Cache Components(cacheComponents: true)를 켜면 기본 동작이다(2026 기준).

  • 코드 스플리팅과 동적 임포트

    코드 스플리팅(Code Splitting)은 하나의 큰 JavaScript 번들을 여러 청크(chunk)로 나누는 빌드 기법이고, 동적 임포트(Dynamic Import, import())는 그 청크를 실제로 필요해질 때 받아오는 방법이다. 나누는 것은 "어떻게", 불러오는 시점은 "언제"의 문제다.

보기 옵션