Next.js App Router 완벽 가이드 — Pages Router에서 뭐가 달라졌나
Next.js App Router의 핵심 개념을 정리한다. Server Component, 레이아웃, 데이터 페칭, 라우팅까지 실무에서 바로 적용 가능한 가이드.
Next.js를 쓰는 사람이라면 한 번쯤 이런 혼란을 겪었을 거다. "이거 App Router에서는 어떻게 하지?" Pages Router에서 잘 되던 코드가 App Router에서는 안 되거나, 반대로 App Router에서만 가능한 패턴이 있거나. 공식 문서가 방대해서 필요한 정보를 빠르게 찾기도 쉽지 않다.
이 글에서는 App Router의 핵심 개념을 실무 관점에서 정리한다. "이론적으로 이렇다"보다는 "실제로 이렇게 쓴다"에 초점을 맞췄다.
폴더가 곧 라우트
App Router의 가장 직관적인 부분. app/ 디렉토리 안의 폴더 구조가 그대로 URL 경로가 된다.
app/
├── page.tsx → /
├── about/
│ └── page.tsx → /about
├── blog/
│ ├── page.tsx → /blog
│ └── [slug]/
│ └── page.tsx → /blog/my-post
page.tsx 파일이 있어야 라우트로 인식된다는 게 포인트다. 폴더만 만들고 page.tsx를 안 넣으면 해당 경로에 접근해도 404가 뜬다. 덕분에 라우트 관련 파일(컴포넌트, 유틸 등)을 같은 폴더에 넣어도 라우트가 오염되지 않는다.
동적 라우트는 대괄호 문법이다. [slug]는 단일 파라미터, [...slug]는 캐치올 라우트, [[...slug]]는 선택적 캐치올. 여기까진 Pages Router와 비슷한데, 파라미터에 접근하는 방식이 달라졌다.
// App Router에서는 params가 Promise
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
// ...
}
Next.js 15부터 params와 searchParams가 비동기로 바뀌었다. 처음에는 좀 번거롭지만, 서버 컴포넌트의 비동기 특성과 맞추기 위한 변경이다.
Server Component가 기본값
App Router에서 가장 큰 변화이자 가장 많은 혼란을 일으키는 부분이다.
app/ 안의 모든 컴포넌트는 기본적으로 Server Component다. 서버에서 렌더링되고, JavaScript 번들에 포함되지 않는다. 이게 무슨 뜻이냐면:
// 이 컴포넌트는 서버에서만 실행된다
export default async function UserList() {
// DB 직접 쿼리 가능!
const users = await db.query('SELECT * FROM users');
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
서버에서 실행되니까 DB에 직접 접근할 수 있고, API 키 같은 시크릿을 쓸 수 있고, 무거운 라이브러리를 import해도 클라이언트 번들 크기에 영향이 없다. 상당히 강력한 개념이다.
대신 제약이 있다. useState, useEffect 못 쓴다. 이벤트 핸들러(onClick 등)도 안 된다. 브라우저 API도 사용 불가. 인터랙션이 필요하면 Client Component로 만들어야 한다.
"use client"; // 이 한 줄이 Client Component로 만든다
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
핵심 원칙은 이거다: 가능한 한 Server Component로 두고, 인터랙션이 필요한 부분만 Client Component로 분리한다. 페이지 전체를 "use client"로 만드는 건 App Router의 장점을 버리는 거다.
실수하기 쉬운 패턴이 있다. Server Component에서 Client Component를 import하는 건 되지만, 그 반대 — Client Component에서 Server Component를 직접 import — 는 주의가 필요하다. 대신 children prop으로 넘기는 패턴을 쓴다.
// ✅ 이렇게 — Server Component를 children으로 전달
<ClientWrapper>
<ServerComponent />
</ClientWrapper>
layout.tsx가 바꾼 레이아웃 관리
Pages Router에서는 _app.tsx에 공통 레이아웃을 넣었다. 페이지가 바뀔 때마다 전체가 리렌더링됐고, 중첩 레이아웃을 만들려면 별도의 패턴이 필요했다.
App Router에서는 layout.tsx가 이 문제를 깔끔하게 해결한다.
// app/layout.tsx — 루트 레이아웃 (필수)
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>
<Header />
<main>{children}</main>
<Footer />
</body>
</html>
);
}
레이아웃은 중첩된다. app/blog/layout.tsx를 만들면 블로그 섹션에만 적용되는 추가 레이아웃이 생기고, 루트 레이아웃 안에 자동으로 들어간다. 그리고 페이지를 이동해도 레이아웃은 리렌더링되지 않는다. 상태가 유지된다는 뜻이다. 사이드바의 스크롤 위치, 검색 입력값 같은 게 날아가지 않는다.
비슷한 파일로 template.tsx도 있는데, 이건 페이지 이동 시마다 새로 마운트된다. 페이지 전환 애니메이션이나 페이지별 초기화가 필요할 때 쓴다. 대부분의 경우에는 layout.tsx만으로 충분하다.
데이터 페칭 — fetch가 달라졌다
Pages Router에서는 getServerSideProps, getStaticProps 같은 전용 함수를 써야 했다. App Router에서는 그냥 컴포넌트 안에서 fetch를 호출하면 된다.
export default async function ProductPage() {
const res = await fetch('https://api.example.com/products');
const products = await res.json();
return <ProductList products={products} />;
}
컴포넌트가 async 함수가 될 수 있다는 게 Server Component의 힘이다. useEffect 안에서 fetch하고, 로딩 상태 관리하고, 에러 핸들링하고... 이런 보일러플레이트가 필요 없다.
캐싱 동작은 Next.js 15에서 좀 바뀌었다. 예전에는 fetch가 기본적으로 캐시됐는데, 지금은 **기본값이 캐시 안 함(no-store)**이다. 캐시가 필요하면 명시적으로 지정해야 한다.
// 캐시하려면 명시적으로
fetch('https://api.example.com/data', { cache: 'force-cache' });
// 시간 기반 재검증
fetch('https://api.example.com/data', { next: { revalidate: 3600 } });
로딩과 에러 처리 — 파일 컨벤션의 힘
App Router의 숨은 강점이 이 부분이다. 특정 이름의 파일만 만들면 로딩 UI와 에러 처리가 자동으로 적용된다.
app/blog/
├── page.tsx → 메인 콘텐츠
├── loading.tsx → 로딩 중 표시될 UI
├── error.tsx → 에러 발생 시 표시될 UI
└── not-found.tsx → 404 표시될 UI
loading.tsx는 React Suspense 위에서 동작한다. 데이터를 페칭하는 동안 자동으로 이 컴포넌트가 보여지고, 데이터가 준비되면 실제 콘텐츠로 교체된다. 스켈레톤 UI를 넣어두면 사용자 경험이 확 좋아진다.
error.tsx는 해당 라우트 세그먼트에서 발생하는 에러를 잡아서 폴백 UI를 보여준다. 에러가 발생해도 레이아웃은 유지되니까, 사용자가 완전히 길을 잃지 않는다. 단, error.tsx는 반드시 Client Component여야 한다 — "use client" 필수.
Server Actions — API 라우트 없이 서버 로직
폼 제출이나 데이터 변경 같은 서버 사이드 로직을, API 라우트 없이 처리하는 기능이다.
// app/contact/page.tsx
export default function ContactPage() {
async function submitForm(formData: FormData) {
"use server";
const email = formData.get("email");
await db.insert({ email });
}
return (
<form action={submitForm}>
<input name="email" type="email" />
<button type="submit">전송</button>
</form>
);
}
"use server" 지시어를 붙이면 해당 함수는 서버에서 실행된다. 클라이언트에서는 이 함수를 호출하면 자동으로 서버로 요청이 간다. API 라우트를 별도로 만들고, fetch 호출을 작성하고, 에러 핸들링을 붙이는 과정이 전부 생략되는 셈이다.
다만 모든 서버 로직을 Server Action으로 처리할 필요는 없다. 외부에서 호출해야 하는 API(모바일 앱, 서드파티 등)는 여전히 Route Handler(app/api/)를 쓰는 게 맞다.
정적 생성 — generateStaticParams
빌드 타임에 정적으로 생성할 페이지를 지정하는 함수다. Pages Router의 getStaticPaths에 해당한다.
export function generateStaticParams() {
return [
{ slug: 'first-post' },
{ slug: 'second-post' },
];
}
블로그처럼 콘텐츠가 미리 정해진 경우에 빌드 시점에 HTML을 생성해두면 로딩 속도가 빨라진다. dynamicParams 옵션으로 목록에 없는 경로의 동작도 제어할 수 있다.
메타데이터 — SEO가 편해졌다
Pages Router에서는 Head 컴포넌트를 직접 import해서 쓰거나 next-seo 같은 라이브러리에 의존했다. App Router에서는 메타데이터 API가 기본 내장이다.
// 정적 메타데이터
export const metadata: Metadata = {
title: "블로그",
description: "블로그 목록 페이지",
};
// 동적 메타데이터
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = getPost(slug);
return {
title: post.title,
description: post.description,
openGraph: { title: post.title, images: [post.thumbnail] },
};
}
generateMetadata를 쓰면 데이터에 따라 메타 태그를 동적으로 생성할 수 있다. OG 이미지, 트위터 카드, canonical URL 같은 것도 전부 이 API로 처리 가능하다.
마이그레이션 — 한번에 안 해도 된다
Pages Router에서 App Router로 넘어가야 하나 고민하는 사람들이 많을 텐데, 좋은 소식은 두 라우터가 공존할 수 있다는 거다. pages/ 디렉토리와 app/ 디렉토리가 동시에 존재해도 된다. 새로운 페이지만 app/에서 만들고, 기존 페이지는 점진적으로 옮기면 된다.
신규 프로젝트라면 고민할 것 없이 App Router로 시작하면 된다. 2026년 기준으로 App Router가 Next.js의 기본이고, 새로운 기능도 전부 App Router 기반으로 나온다. Pages Router가 당장 사라지진 않겠지만, 방향성은 확실하다.