본문으로 건너뛰기
Advertisement

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],
},
};
}
Advertisement