콘텐츠로 이동
Study Note프론트엔드

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.tsxnotFound() 호출 또는 매칭 실패 시
template.tsxlayout과 비슷하지만 이동할 때마다 새로 마운트된다
route.ts페이지 대신 HTTP 핸들러 (REST API)
default.tsx병렬 라우트에서 매칭 실패 시 대체 UI

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

중첩 레이아웃은 유지되고 page만 교체되는 라우트 트리
  • /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}</>
}

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

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.ts가 proxy.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).*)'],
}
터미널 창
npx @next/codemod@canary middleware-to-proxy . # 자동 마이그레이션

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

  • 이름을 “proxy”로 바꾼 이유 자체가 “최후의 수단으로만 쓰라”는 신호다
  • Express 미들웨어와 혼동해 인증 로직 전체를 여기 넣는 사례가 많았다
  • Server Function은 별도 라우트가 아니다. matcher가 그 경로를 제외하면 함께 빠진다
  • matcher를 리팩터링하다 인증이 조용히 사라지는 사고가 실제로 발생한다
proxy.ts matcher를 지나 데이터 지점에서 인증·인가를 다시 하는지에 따라 갈리는 판단
  • 폴더가 URL이고, page.tsx가 있어야 접근 가능해진다
  • layout은 이동해도 리렌더되지 않는다 — 상태를 두어도 안전하다
  • params / searchParams는 Promise다. await한다
  • 라우트 그룹 (name) 으로 껍데기를 분리하는 게 표준 패턴
  • 화면용 데이터에 Route Handler를 만들지 말 것 — 서버 컴포넌트에서 직접 조회
  • middleware.ts → proxy.ts. 인증의 최종 방어선으로 쓰지 말 것