콘텐츠로 이동

3. App Router

설정 파일 대신 폴더 구조가 라우팅을 정의한다

Next.js는 라우팅 설정 파일을 쓰지 않는다. 폴더 이름이 경로가 되고, 파일 이름이 역할이 된다.

  • 디렉터리app/
    • layout.tsx 모든 페이지를 감싸는 껍데기 (필수)
    • page.tsx /
    • loading.tsx 로딩 중 보여줄 UI
    • error.tsx 에러 경계
    • not-found.tsx 404
    • 디렉터리posts/
      • page.tsx /posts
      • 디렉터리[slug]/
        • page.tsx /posts/hello-world
    • 디렉터리(marketing)/ URL에 안 나타나는 그룹
      • 디렉터리about/
        • page.tsx /about

폴더는 경로를 만들고, page.tsx가 있어야 실제로 접근 가능해진다. page.tsx 없는 폴더는 URL이 되지 않는다 — 조각을 모아두는 용도로 쓸 수 있다.

파일 역할
layout.tsx 자식 라우트를 감싸는 공유 UI. 이동해도 리렌더되지 않는다
page.tsx 해당 경로의 실제 화면. 이게 있어야 URL이 생긴다
loading.tsx 자동으로 Suspense 경계를 만들어 준다
error.tsx 자동으로 에러 경계를 만든다. 클라이언트 컴포넌트여야 한다
not-found.tsx notFound() 호출 또는 매칭 실패 시
template.tsx layout과 비슷하지만 이동할 때마다 새로 마운트된다
route.ts 페이지 대신 HTTP 핸들러 (REST API)
default.tsx 병렬 라우트에서 매칭 실패 시 대체 UI

App Router의 중요한 장점인데 잘 알려져 있지 않다.

flowchart TB
    A["app/layout.tsx<br/>사이드바 · 스크롤 위치 유지"] --> B["app/dashboard/layout.tsx<br/>탭 상태 유지"]
    B --> C["dashboard/reports/page.tsx<br/>← 이동 시 이것만 교체"]
    B --> D["dashboard/settings/page.tsx<br/>← 이것으로"]

    classDef ok   fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef warn fill:#fef3c7,stroke:#d97706,color:#78350f
    class A,B ok
    class C,D warn
  • /dashboard/reports/dashboard/settings 이동 시 바뀌는 건 page뿐이다
  • 사이드바의 스크롤 위치, 열린 아코디언, 재생 중인 오디오가 그대로 살아 있다
  • 그래서 레이아웃에 상태를 두어도 안전하다
app/posts/[slug]/page.tsx → /posts/abc params.slug = 'abc'
app/shop/[...cat]/page.tsx → /shop/a/b/c params.cat = ['a','b','c']
app/docs/[[...path]]/page.tsx → /docs 또는 /docs/a (optional catch-all)
// params는 Promise다 — Next.js 15부터 바뀌었다. await 해야 한다.
export default async function PostPage({ params }: PageProps<'/posts/[slug]'>) {
const { slug } = await params
const post = await getPost(slug)
if (!post) notFound()
return <article>{post.title}</article>
}
// 빌드 타임에 미리 생성할 경로를 알려준다
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((p) => ({ slug: p.slug }))
}

PageProps<'/posts/[slug]'>는 Next.js가 자동 생성해 주는 타입이다. 직접 정의할 필요가 없다.

export default async function SearchPage({ searchParams }: PageProps<'/search'>) {
const { q, page } = await searchParams
const results = await search(q, Number(page ?? 1))
return <ResultList items={results} />
}

괄호로 감싼 폴더는 URL에 나타나지 않는다. 레이아웃을 나눌 때 쓴다.

  • 디렉터리app/
    • 디렉터리(marketing)/
      • layout.tsx 마케팅용 헤더/푸터
      • page.tsx /
      • 디렉터리pricing/
        • page.tsx /pricing
    • 디렉터리(app)/
      • layout.tsx 앱용 사이드바 (로그인 필요)
      • 디렉터리dashboard/
        • page.tsx /dashboard
      • 디렉터리settings/
        • page.tsx /settings

랜딩 페이지와 로그인 후 화면의 껍데기가 완전히 다른 경우가 대부분이다. 이 구조가 사실상 표준 패턴이라고 봐도 된다.

이름은 어렵지만 용도는 명확하다.

app/dashboard/
├─ layout.tsx
├─ @team/page.tsx
├─ @analytics/page.tsx
└─ page.tsx
export default function Layout({ children, team, analytics }) {
return <>{children}{team}{analytics}</>
}

한 화면에 독립적인 영역 여러 개를 둔다. 각 슬롯이 자기 loadingerror를 따로 가진다 — 하나가 느려도 나머지는 뜬다.

Route Handler — 진짜 API가 필요할 때

섹션 제목: “Route Handler — 진짜 API가 필요할 때”
app/api/posts/route.ts
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const limit = Number(searchParams.get('limit') ?? 10)
const posts = await db.post.findMany({ take: limit })
return Response.json(posts)
}
export async function POST(request: Request) {
const body = await request.json()
const created = await db.post.create({ data: body })
return Response.json(created, { status: 201 })
}
import Link from 'next/link'
// 기본 — 뷰포트에 들어오면 자동으로 프리페치된다
<Link href="/posts/hello">읽기</Link>
<Link href="/posts/hello" prefetch={false}>프리페치 끄기</Link>
// 프로그래매틱 이동은 클라이언트 컴포넌트에서
'use client'
import { useRouter } from 'next/navigation'
export function SaveButton() {
const router = useRouter()
return <button onClick={() => router.push('/done')}>저장</button>
}

로딩과 에러 — 파일만 두면 된다

섹션 제목: “로딩과 에러 — 파일만 두면 된다”

loading.tsx

app/posts/loading.tsx
export default function Loading() {
return <PostListSkeleton />
}

이 파일이 있으면 Next.js가 page.tsx를 자동으로 Suspense로 감싼다.

error.tsx

app/posts/error.tsx
'use client' // 필수
export default function Error({ error, reset }) {
return (
<div>
<p>불러오지 못했습니다</p>
<button onClick={reset}>다시 시도</button>
</div>
)
}

Next.js 16에서 middleware.tsproxy.ts로 이름이 바뀌었다. 런타임 기본값도 Edge에서 Node.js로 바뀌었다.

// proxy.ts (프로젝트 루트, app/ 과 같은 레벨)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
if (!request.cookies.get('session')) {
return NextResponse.redirect(new URL('/login', request.url))
}
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}
Terminal window
npx @next/codemod@canary middleware-to-proxy . # 자동 마이그레이션

Next.js 팀이 문서에서 명시적으로 경고하는 지점이다.

  • 이름을 “proxy”로 바꾼 이유 자체가 **“최후의 수단으로만 쓰라”**는 신호다
  • Express 미들웨어와 혼동해 인증 로직 전체를 여기 넣는 사례가 많았다
  • Server Function은 별도 라우트가 아니다. matcher가 그 경로를 제외하면 함께 빠진다
  • matcher를 리팩터링하다 인증이 조용히 사라지는 사고가 실제로 발생한다
flowchart TD
    R["요청"] --> P{"proxy.ts matcher 에<br/>걸리는가"}
    P -->|"걸린다"| C1["쿠키 확인 → 리다이렉트"]
    P -->|"안 걸린다"| C2["그냥 통과"]
    C1 --> D["데이터에 닿는 지점"]
    C2 --> D
    D --> A{"여기서 다시<br/>인증·인가를 하는가"}
    A -->|"한다"| OK["안전 ✅"]
    A -->|"proxy 를 믿는다"| BAD["Server Function 직접 호출로 우회 가능 ❌"]

    classDef ok   fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef bad  fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class OK ok
    class BAD bad
    class R,P,C1,C2,D,A mute
  • 폴더가 URL이고, page.tsx가 있어야 접근 가능해진다
  • layout은 이동해도 리렌더되지 않는다 — 상태를 두어도 안전하다
  • params / searchParamsPromise다. await한다
  • 라우트 그룹 (name) 으로 껍데기를 분리하는 게 표준 패턴
  • 화면용 데이터에 Route Handler를 만들지 말 것 — 서버 컴포넌트에서 직접 조회
  • middleware.tsproxy.ts. 인증의 최종 방어선으로 쓰지 말 것