Next.jsNext.js 기초 · 2기초

페이지와 라우팅 — 동적 라우트와 레이아웃

Next.js라우팅동적라우트레이아웃Link

파일 기반 라우팅

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프로그래밍 이동

다음 편에서는 데이터 패칭 — 서버 컴포넌트에서 데이터를 가져오고 캐싱하는 방법을 배웁니다.

궁금한 점이 있으신가요?

협업·의뢰는 아래로, 가벼운 소통은 인스타그램 @bluefox._.hi도 환영이에요.