11.1 App Router 타입 시스템 — Page, Layout, params 타입
Next.js App Router의 파일 기반 라우팅
Next.js 13+ App Router는 파일 시스템 기반 라우팅을 사용합니다. TypeScript와 함께 사용할 때 각 파일의 타입을 정확히 이해해야 합니다.
app/
├── layout.tsx # 루트 레이아웃
├── page.tsx # 홈 페이지 (/)
├── blog/
│ ├── page.tsx # /blog
│ └── [slug]/
│ └── page.tsx # /blog/:slug
└── dashboard/
├── layout.tsx # 대시보드 레이아웃
└── page.tsx # /dashboard
Page 컴포넌트 타입
정적 페이지
// app/page.tsx
export default function HomePage() {
return <h1>홈</h1>;
}
params와 searchParams
// app/blog/[slug]/page.tsx
// Next.js 15+ (비동기 params)
interface PageProps {
params: Promise<{ slug: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function BlogPost({ params, searchParams }: PageProps) {
const { slug } = await params;
const { page, sort } = await searchParams;
const post = await getPostBySlug(slug);
return (
<article>
<h1>{post.title}</h1>
<p>페이지: {page ?? '1'}</p>
</article>
);
}
// Next.js 14 이하 (동기 params)
interface PageProps {
params: { slug: string };
searchParams: { [key: string]: string | string[] | undefined };
}
export default function BlogPost({ params, searchParams }: PageProps) {
const { slug } = params;
// ...
}
중첩 동적 라우트
// app/shop/[category]/[productId]/page.tsx
interface PageProps {
params: Promise<{
category: string;
productId: string;
}>;
}
export default async function ProductPage({ params }: PageProps) {
const { category, productId } = await params;
const product = await getProduct(category, productId);
return <ProductDetail product={product} />;
}
Catch-all 라우트
// app/docs/[...slug]/page.tsx
interface PageProps {
params: Promise<{ slug: string[] }>;
}
// app/docs/[...slug] 접근 시: slug = ['getting-started', 'installation']
// Optional catch-all: app/docs/[[...slug]]/page.tsx
interface OptionalPageProps {
params: Promise<{ slug?: string[] }>;
}
generateStaticParams
빌드 시 정적으로 생성할 경로를 정의합니다.
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getPosts();
// 반환 타입: { slug: string }[]
return posts.map(post => ({
slug: post.slug,
}));
}
// 중첩 동적 라우트
// app/[category]/[id]/page.tsx
export async function generateStaticParams() {
const categories = await getCategories();
const params: { category: string; id: string }[] = [];
for (const category of categories) {
const items = await getItemsByCategory(category.slug);
items.forEach(item => {
params.push({ category: category.slug, id: item.id });
});
}
return params;
}
Layout 컴포넌트 타입
// app/layout.tsx (루트 레이아웃)
import { ReactNode } from 'react';
interface RootLayoutProps {
children: ReactNode;
}
export default function RootLayout({ children }: RootLayoutProps) {
return (
<html lang="ko">
<body>{children}</body>
</html>
);
}
중첩 레이아웃
// app/dashboard/layout.tsx
interface DashboardLayoutProps {
children: ReactNode;
}
export default function DashboardLayout({ children }: DashboardLayoutProps) {
return (
<div className="dashboard">
<Sidebar />
<main>{children}</main>
</div>
);
}
병렬 라우트 레이아웃
// app/layout.tsx — @modal 슬롯 포함
interface RootLayoutProps {
children: ReactNode;
modal: ReactNode; // @modal 슬롯
}
export default function RootLayout({ children, modal }: RootLayoutProps) {
return (
<html>
<body>
{children}
{modal}
</body>
</html>
);
}
특수 파일 타입
loading.tsx
// app/blog/loading.tsx
export default function Loading() {
return <div>로딩 중...</div>;
}
error.tsx
// app/blog/error.tsx
'use client';
interface ErrorProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function Error({ error, reset }: ErrorProps) {
return (
<div>
<h2>오류가 발생했습니다</h2>
<p>{error.message}</p>
<button onClick={reset}>다시 시도</button>
</div>
);
}
not-found.tsx
// app/not-found.tsx
export default function NotFound() {
return (
<div>
<h2>404 - 페이지를 찾을 수 없습니다</h2>
</div>
);
}
고수 팁
1. 라우트 타입 안전성 — next-safe-navigation
import { createNavigationConfig } from 'next-safe-navigation';
import { z } from 'zod';
const { routes, useSafeParams, useSafeSearchParams } =
createNavigationConfig((defineRoute) => ({
home: defineRoute('/'),
blog: defineRoute('/blog'),
blogPost: defineRoute('/blog/[slug]', {
params: z.object({ slug: z.string() }),
}),
search: defineRoute('/search', {
search: z.object({
q: z.string().optional(),
page: z.coerce.number().default(1),
}),
}),
}));
// 타입 안전한 링크
<Link href={routes.blogPost({ params: { slug: 'hello-world' } })} />
// 타입 안전한 params 접근
const { slug } = useSafeParams('blogPost');
2. generateMetadata 타입
import { Metadata, ResolvingMetadata } from 'next';
interface PageProps {
params: Promise<{ slug: string }>;
}
export async function generateMetadata(
{ params }: PageProps,
parent: ResolvingMetadata
): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
return {
title: post.title,
description: post.excerpt,
openGraph: {
images: [post.coverImage],
},
};
}