파일 기반 라우팅
app/
├── page.tsx → /
├── about/page.tsx → /about
├── blog/
│ ├── page.tsx → /blog
│ └── [slug]/page.tsx → /blog/my-post
├── shop/
│ └── [...category]/ → /shop/a/b/c (catch-all)
│ └── page.tsx
└── (auth)/ → 라우트 그룹 (URL에 영향 없음)
├── login/page.tsx → /login
└── signup/page.tsx → /signup
동적 라우트
// app/users/[id]/page.tsx
interface UserPageProps {
params: { id: string };
searchParams: { tab?: string };
}
export default async function UserPage({ params, searchParams }: UserPageProps) {
const user = await fetchUser(params.id);
const tab = searchParams.tab ?? "profile";
return (
<div>
<h1>{user.name}</h1>
<p>탭: {tab}</p>
</div>
);
}
// generateStaticParams: 빌드 시 정적 생성
export async function generateStaticParams() {
const users = await fetchAllUsers();
return users.map(user => ({ id: user.id }));
}
중첩 레이아웃
// app/dashboard/layout.tsx
// /dashboard/* 모든 경로에 적용됨
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex">
<aside className="w-64 bg-gray-100">
<nav>
<a href="/dashboard">홈</a>
<a href="/dashboard/analytics">분석</a>
<a href="/dashboard/settings">설정</a>
</nav>
</aside>
<main className="flex-1 p-6">{children}</main>
</div>
);
}
라우트 그룹
URL에 영향 없이 폴더로 구분할 때 사용합니다.
app/
├── (marketing)/ ← URL에 포함 안 됨
│ ├── layout.tsx ← 마케팅 레이아웃
│ ├── page.tsx → /
│ └── about/page.tsx → /about
└── (dashboard)/
├── layout.tsx ← 대시보드 레이아웃
└── analytics/
└── page.tsx → /analytics
Link 컴포넌트
import Link from "next/link";
function Navigation() {
return (
<nav>
{/* 기본 */}
<Link href="/">홈</Link>
{/* 동적 */}
<Link href={`/users/${userId}`}>프로필</Link>
{/* 쿼리 파라미터 */}
<Link href={{ pathname: "/search", query: { q: "react" } }}>
검색
</Link>
{/* prefetch 비활성 */}
<Link href="/heavy-page" prefetch={false}>
무거운 페이지
</Link>
</nav>
);
}
프로그래밍 방식 이동
"use client";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
function SearchForm() {
const router = useRouter();
const pathname = usePathname(); // 현재 경로
const searchParams = useSearchParams();
function handleSearch(query: string) {
const params = new URLSearchParams(searchParams.toString());
params.set("q", query);
router.push(`${pathname}?${params.toString()}`);
}
function goBack() {
router.back();
}
function refresh() {
router.refresh(); // 서버 컴포넌트 데이터 재요청
}
}
특수 파일
app/
├── page.tsx ← 페이지 UI
├── layout.tsx ← 레이아웃 (유지됨)
├── loading.tsx ← 로딩 UI (Suspense 자동 적용)
├── error.tsx ← 에러 UI (Error Boundary 자동 적용)
├── not-found.tsx ← 404 UI
└── template.tsx ← 레이아웃과 유사하나 매번 새로 마운트
// app/loading.tsx
export default function Loading() {
return <div className="spinner">로딩 중...</div>;
}
// app/error.tsx
"use client";
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div>
<p>오류 발생: {error.message}</p>
<button onClick={reset}>다시 시도</button>
</div>
);
}
정리
| 파일/폴더 | 역할 |
|---|---|
page.tsx | 라우트 UI |
layout.tsx | 공유 레이아웃 |
loading.tsx | 로딩 상태 |
error.tsx | 에러 상태 |
[param] | 동적 세그먼트 |
(group) | URL 없는 폴더 그룹 |
Link | 클라이언트 사이드 이동 |
useRouter | 프로그래밍 이동 |
다음 편에서는 데이터 패칭 — 서버 컴포넌트에서 데이터를 가져오고 캐싱하는 방법을 배웁니다.