Next.js 동적 라우트 핵심 요약
app/posts/[slug]/page.tsx에서 대괄호로 감싼 폴더는 URL 값을 받는 동적 segment입니다. /posts/hello-next 요청의 slug는 hello-next가 됩니다. 현재 App Router에서는 params가 Promise이므로 Server Component에서는 await params, Client Component page에서는 React use(params) 또는 useParams()로 읽습니다.
검증 기준: 2026년 7월 19일 확인한 Next.js 16.2.10 App Router 공식 문서와 TypeScript 예제입니다. Pages Router의 router.query 예제와 섞지 마세요.
- [slug] 폴더와 Promise params
- 목록 Link부터 상세 조회·404까지
- Client Component의 use와 useParams
- path params와 searchParams 차이
- id·slug 선택과 중첩 route
- catch-all과 타입
- generateStaticParams의 범위
- 404·slug 변경·보안 정책
[slug] 폴더가 URL 값을 받는다
정적 route는 폴더 이름과 URL이 그대로 대응합니다. app/about/page.tsx는 /about을 처리합니다. 글, 상품, 사용자처럼 값이 미리 하나로 정해지지 않은 화면은 폴더 이름을 [slug] 또는 [id]처럼 작성합니다. 대괄호 안의 이름이 params 객체의 키가 됩니다.
![Next.js 동적 라우트 완전 정리: [slug], params, catch-all 1 Next.js 정적 라우트와 동적 라우트 비교](https://blogflow.kr/wp-content/uploads/2026/07/ebb3b8ebacb8-ec82bdec9e85ec9aa9-ec9db4ebafb8eca780-1-20260715-073201.webp)
app/
about/
page.tsx → /about
posts/
[slug]/
page.tsx → /posts/hello-next
// app/posts/[slug]/page.tsx
type Props = {
params: Promise<{ slug: string }>
}
export default async function PostPage({ params }: Props) {
const { slug } = await params
return <main>게시글 slug: {slug}</main>
}
이 예제에서 /posts/hello-next로 들어오면 slug는 문자열 hello-next입니다. 예전 App Router 예제처럼 params를 동기 객체로 선언하거나 값을 즉시 읽지 않습니다. Next.js 15에는 호환 동작이 있었지만 현재 문서는 Promise 접근을 기준으로 합니다.
목록 Link → 상세 조회 → notFound 흐름
동적 route는 폴더 문법만으로 끝나지 않습니다. 목록에서 올바른 URL을 만들고, 상세 page가 URL 값으로 데이터를 조회하고, 데이터가 없을 때 실제 404를 반환해야 완성됩니다.
// app/posts/page.tsx
import Link from 'next/link'
export default async function PostsPage() {
const posts = await getPosts()
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
<Link href={`/posts/${encodeURIComponent(post.slug)}`}>
{post.title}
</Link>
</li>
))}
</ul>
)
}
// app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation'
type Props = { params: Promise<{ slug: string }> }
export default async function PostPage({ params }: Props) {
const { slug } = await params
const post = await getPostBySlug(slug)
if (!post) notFound()
return (
<article>
<h1>{post.title}</h1>
<p>{post.description}</p>
</article>
)
}
없는 slug에 빈 layout이나 “데이터 없음” 문구만 200으로 반환하면 사용자와 검색 엔진이 URL 상태를 오해할 수 있습니다. 정말 존재하지 않는 리소스라면 notFound()를 호출하고 not-found.tsx에서 사용자에게 다음 이동 경로를 제공하세요.
![Next.js 동적 라우트 완전 정리: [slug], params, catch-all 2 /posts/[id]와 /posts/123이 params로 연결되는 흐름](https://blogflow.kr/wp-content/uploads/2026/07/ebb3b8ebacb8-ec82bdec9e85ec9aa9-ec9db4ebafb8eca780-2-20260715-073346.webp)
Client Component에서는 use 또는 useParams를 쓴다
page 자체가 Client Component라면 props로 받은 Promise를 React use로 풀 수 있습니다. route 트리 아래의 다른 Client Component에서 현재 segment가 필요하면 useParams를 사용할 수 있습니다. 데이터 조회와 초기 렌더링을 위해 page 전체를 Client Component로 바꿀 필요는 없습니다. 서버에서 처리할 수 있는 page는 Server Component로 유지하고 상호작용 부분만 분리하는 편이 단순합니다.
// app/posts/[slug]/page.tsx
'use client'
import { use } from 'react'
type Props = { params: Promise<{ slug: string }> }
export default function PostPage({ params }: Props) {
const { slug } = use(params)
return <p>현재 slug: {slug}</p>
}
'use client'
import { useParams } from 'next/navigation'
export function PostToolbar() {
const { slug } = useParams<{ slug: string }>()
return <button type='button'>{slug} 공유</button>
}
path params와 searchParams는 역할이 다르다
| 구분 | 예시 | 적합한 값 |
|---|---|---|
| path params | /products/123 |
어떤 리소스나 계층인지 결정하는 id·slug |
| search params | /products?sort=price&page=2 |
정렬, 필터, 페이지처럼 같은 목록의 표시 조건 |
상세 상품의 정체성을 query string에만 두기보다 /products/[id]처럼 경로로 표현하는 편이 구조를 이해하기 쉽습니다. 반대로 정렬 조건마다 별도 폴더를 만들면 route가 지나치게 늘어납니다. searchParams도 App Router page에서 Promise이므로 서버에서 읽을 때 await하는 현재 API 규격을 확인하세요.
id, slug, 둘 다 쓰는 기준
[id]와 [slug]는 Next.js 기능이 다른 것이 아니라 이름과 데이터 모델의 선택입니다. 안정적인 내부 식별자가 필요하면 id가 단순하고, 사람이 읽고 공유하기 좋은 URL이 중요하면 slug가 적합합니다. 제목에서 slug를 자동 생성한다면 제목 변경과 URL 변경을 분리할지 먼저 정해야 합니다.
| 구조 | 장점 | 주의점 |
|---|---|---|
/products/[id] |
조회 키가 안정적이고 중복 관리가 쉬움 | URL만 보고 내용을 추측하기 어려움 |
/posts/[slug] |
읽고 공유하기 쉬움 | 고유성·변경·예약어 정책 필요 |
/products/[id]/[slug] |
조회 안정성과 설명 가능한 URL을 함께 사용 | id와 slug 불일치 시 canonical URL로 교정 필요 |
app/
products/
[id]/
[slug]/
page.tsx
URL: /products/123/wireless-keyboard
params: { id: '123', slug: 'wireless-keyboard' }
중첩 route에서는 모든 segment를 Promise 안의 하나의 객체로 받습니다. 실제 조회는 id로 하고 저장된 canonical slug와 URL slug가 다르면 영구 리디렉션하는 방식이 가능합니다. Next.js의 permanentRedirect()는 기본적으로 308을 반환합니다. 운영 정책이 정확히 301을 요구한다면 플랫폼 또는 서버 리디렉션 설정을 사용하고, 어느 쪽이든 한 번의 영구 리디렉션으로 끝나게 하세요.
import { notFound, permanentRedirect } from 'next/navigation'
type Props = {
params: Promise<{ id: string; slug: string }>
}
export default async function ProductPage({ params }: Props) {
const { id, slug } = await params
const product = await getProductById(id)
if (!product) notFound()
if (slug !== product.slug) {
permanentRedirect(`/products/${product.id}/${product.slug}`)
}
return <h1>{product.name}</h1>
}
catch-all과 optional catch-all 타입
경로 깊이가 일정하지 않은 문서나 카테고리 트리는 catch-all segment로 받을 수 있습니다. 일반 동적 segment는 문자열 하나, catch-all은 문자열 배열, optional catch-all은 배열 또는 undefined입니다.
| 폴더 | 일치하는 URL | params 타입 |
|---|---|---|
[slug] |
/blog/a |
{ slug: string } |
[...slug] |
/shop/a, /shop/a/b |
{ slug: string[] } |
[[...slug]] |
/shop, /shop/a/b |
{ slug?: string[] } |
// app/docs/[[...segments]]/page.tsx
type Props = {
params: Promise<{ segments?: string[] }>
}
export default async function DocsPage({ params }: Props) {
const { segments } = await params
const path = segments?.join('/') ?? 'index'
return <p>문서 경로: {path}</p>
}
catch-all을 쓰기 전에 고정된 route로 표현할 수 없는 구조인지 확인하세요. 너무 넓은 catch-all은 의도하지 않은 URL까지 매치하고 404 정책을 어렵게 만들 수 있습니다.
generateStaticParams는 사전 렌더링할 값을 제공한다
generateStaticParams는 동적 route의 일부 또는 전체 params를 빌드 시 제공합니다. 반환한 값은 사전 렌더링 대상이지만 반환하지 않은 모든 URL이 자동으로 404가 되는 것은 아닙니다. 기본 동작에서는 나머지 경로가 첫 요청에 렌더링될 수 있으며, 반환 목록 밖의 경로를 막으려면 dynamicParams = false 정책을 명시합니다.
export async function generateStaticParams() {
const posts = await getPublishedPosts()
return posts.map((post) => ({ slug: post.slug }))
}
type Props = { params: Promise<{ slug: string }> }
export default async function PostPage({ params }: Props) {
const { slug } = await params
// ...
}
일부만 빌드하고 나머지는 요청 시 생성할지, 목록 밖 URL을 404로 할지는 콘텐츠 규모와 배포 정책에 따라 정하세요. Cache Components 사용 여부에 따라 빈 배열과 runtime params 처리 조건이 달라지므로 현재 프로젝트 설정의 공식 문서를 함께 확인해야 합니다.
실무 404·slug 변경·보안 체크리스트
- slug는 데이터 저장 단계에서 고유성, 허용 문자, 최대 길이, 예약어를 검증합니다.
- URL에서 받은 값은 신뢰하지 말고 데이터 조회와 권한 검사를 서버에서 다시 수행합니다.
- 이메일, 토큰, 주민번호, 내부 순번처럼 공개하면 안 되는 식별자를 경로에 넣지 않습니다. URL은 기록·로그·분석 도구·공유 화면에 남습니다.
- 없는 데이터는
notFound()로 실제 404를 반환합니다. - slug를 변경하면 기존 URL을 새 canonical URL로 한 번만 영구 리디렉션하고 내부 링크도 최종 URL로 바꿉니다.
- 목록의
Link, 상세 조회, metadata canonical이 같은 slug 정책을 쓰는지 테스트합니다. - catch-all URL에는 허용 깊이와 잘못된 조합의 404 처리를 둡니다.
공식 문서
- Next.js Dynamic Route Segments
- Next.js generateStaticParams
- Next.js useParams
- Next.js permanentRedirect
다음 단계
동적 상세 페이지의 검색 제목과 canonical을 데이터에 맞춰 만들려면 Next.js Metadata API 완전 정리에서 Promise params를 사용하는 generateMetadata 예제를 이어서 확인하세요.