Перейти к содержимому

19. Динамические маршруты

🎯 Динамические маршруты: углублённый разбор

Заголовок раздела «🎯 Динамические маршруты: углублённый разбор»

Динамические маршруты — основа любого реального приложения. Страница товара, профиль пользователя, пост блога — всё это динамические маршруты. Next.js предлагает несколько видов: простые [param], catch-all [...slug], опциональные [[...slug]] и множество инструментов для работы с ними. Разберём всё по косточкам! 🦴


app/
├── blog/
│ └── [slug]/
│ └── page.tsx # /blog/my-post
├── products/
│ └── [category]/
│ └── [id]/
│ └── page.tsx # /products/electronics/42
├── docs/
│ └── [...slug]/
│ └── page.tsx # /docs/a, /docs/a/b, /docs/a/b/c
└── shop/
└── [[...filters]]/
└── page.tsx # /shop, /shop/sale, /shop/sale/under-100
// [slug] — одиночный динамический сегмент
// Совпадает: /blog/hello-world
// НЕ совпадает: /blog/a/b
// [...slug] — catch-all (один и более сегментов)
// Совпадает: /docs/a, /docs/a/b, /docs/a/b/c
// НЕ совпадает: /docs (без сегментов!)
// [[...slug]] — optional catch-all (ноль и более сегментов)
// Совпадает: /shop, /shop/sale, /shop/sale/under-100

В Next.js 15 params — это Promise, нужно await:

app/blog/[slug]/page.tsx
interface Props {
params: Promise<{ slug: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function BlogPost({ params, searchParams }: Props) {
// В Next.js 15: params — это Promise!
const { slug } = await params;
const { page, sort } = await searchParams;
const post = await getPost(slug);
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
// Вложенные параметры
// app/products/[category]/[id]/page.tsx
interface NestedProps {
params: Promise<{ category: string; id: string }>;
}
export default async function ProductPage({ params }: NestedProps) {
const { category, id } = await params;
const product = await getProduct(category, id);
return <ProductDetail product={product} />;
}

app/docs/[...slug]/page.tsx
interface DocsProps {
params: Promise<{ slug: string[] }>; // Массив!
}
export default async function DocsPage({ params }: DocsProps) {
const { slug } = await params;
// /docs/getting-started → slug = ['getting-started']
// /docs/api/users → slug = ['api', 'users']
// /docs/guides/auth/oauth → slug = ['guides', 'auth', 'oauth']
// Строим путь для MDX файла
const filePath = slug.join('/'); // 'guides/auth/oauth'
const doc = await getDoc(filePath);
return (
<div className="docs-layout">
<DocsBreadcrumb segments={slug} />
<article>{doc.content}</article>
</div>
);
}
// Хлебные крошки из сегментов
function DocsBreadcrumb({ segments }: { segments: string[] }) {
return (
<nav>
{segments.map((segment, i) => (
<span key={i}>
{i > 0 && ' › '}
<a href={`/docs/${segments.slice(0, i + 1).join('/')}`}>
{segment.replace(/-/g, ' ')}
</a>
</span>
))}
</nav>
);
}

app/shop/[[...filters]]/page.tsx
interface ShopProps {
params: Promise<{ filters?: string[] }>; // Опциональный!
}
export default async function ShopPage({ params }: ShopProps) {
const { filters = [] } = await params;
// /shop → filters = [] или undefined
// /shop/sale → filters = ['sale']
// /shop/sale/men → filters = ['sale', 'men']
// Парсим фильтры из URL-сегментов
const [category, subcategory, priceRange] = filters;
const products = await getProducts({
category: category ?? 'all',
subcategory,
priceRange,
});
return (
<div>
<ShopFilters current={filters} />
<ProductGrid products={products} />
</div>
);
}

generateStaticParams — аналог getStaticPaths из Pages Router. Говорит Next.js, какие пути нужно пресгенерировать во время сборки:

app/blog/[slug]/page.tsx
import { getAllPosts } from '@/lib/blog';
// Выполняется во время BUILD
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map(post => ({
slug: post.slug,
}));
// Возвращает: [{ slug: 'hello-world' }, { slug: 'next-js-guide' }, ...]
}
// Next.js пресгенерирует:
// /blog/hello-world → статический HTML
// /blog/next-js-guide → статический HTML
// ...
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
return <article>{post.content}</article>;
}

app/products/[category]/[id]/page.tsx
export async function generateStaticParams() {
const products = await getAllProducts();
return products.map(product => ({
category: product.category.slug,
id: product.id.toString(),
}));
// [{ category: 'electronics', id: '1' }, ...]
}
// Оптимизация: родительский generateStaticParams можно переиспользовать
// app/products/[category]/page.tsx
export async function generateStaticParams() {
const categories = await getCategories();
return categories.map(cat => ({ category: cat.slug }));
}
// app/products/[category]/[id]/page.tsx
export async function generateStaticParams({
params: { category },
}: {
params: { category: string };
}) {
// Вызывается отдельно для каждой категории!
const products = await getProductsByCategory(category);
return products.map(p => ({ id: p.id.toString() }));
}

app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
// Пост не найден → показываем 404
if (!post) {
notFound(); // выбрасывает NEXT_NOT_FOUND
// Код ниже не выполнится!
}
return <article>{post.content}</article>;
}
// app/blog/[slug]/not-found.tsx — кастомная 404 для этого сегмента
export default function BlogNotFound() {
return (
<div className="text-center py-20">
<h1 className="text-4xl font-bold">404</h1>
<p className="text-gray-600 mt-2">Статья не найдена</p>
<a href="/blog" className="mt-4 inline-block text-blue-600 hover:underline">
← Вернуться в блог
</a>
</div>
);
}
// app/not-found.tsx — глобальная 404
export default function GlobalNotFound() {
return (
<div className="text-center py-20">
<h1 className="text-6xl font-black">404</h1>
<p>Страница не найдена</p>
</div>
);
}

app/old-blog/[slug]/page.tsx
import { redirect, permanentRedirect } from 'next/navigation';
export default async function OldBlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
// Временный редирект (307)
redirect(`/blog/${slug}`);
// ВАЖНО: redirect() выбрасывает исключение!
// Не оборачивай в try-catch без повторного throw!
// Постоянный редирект (308)
permanentRedirect(`/blog/${slug}`);
}
// Правильная обработка redirect в Server Action:
'use server';
import { redirect } from 'next/navigation';
export async function createPost(formData: FormData) {
try {
const post = await db.post.create({ data: { title: formData.get('title') as string } });
// ❌ Неправильно: redirect внутри try → не сработает
// redirect(`/blog/${post.slug}`);
} catch (error) {
if (error instanceof SomeError) throw error;
return { error: 'Что-то пошло не так' };
}
// ✅ Правильно: redirect вне try-catch
const post = await db.post.create({ data: { title: formData.get('title') as string } });
redirect(`/blog/${post.slug}`);
}

📝 Типизированные маршруты (next-types / experimental)

Заголовок раздела «📝 Типизированные маршруты (next-types / experimental)»
// next.config.mjs — включаем типизацию
const nextConfig = {
experimental: {
typedRoutes: true,
},
};
// Теперь Link и useRouter знают о существующих маршрутах!
import Link from 'next/link';
// ✅ TypeScript знает, что этот маршрут существует
<Link href="/blog/my-post">Статья</Link>
// ❌ TypeScript ошибка: маршрут не существует
<Link href="/blogg/my-post">Статья</Link>
// Динамические маршруты с типизацией
import { useRouter } from 'next/navigation';
const router = useRouter();
// ✅ Типизированная навигация
router.push('/blog/[slug]', { slug: 'my-post' });
// С Route type
import type { Route } from 'next';
function NavLink<T extends string>({ href, children }: {
href: Route<T>;
children: React.ReactNode;
}) {
return <Link href={href}>{children}</Link>;
}

🏗️ Полный пример: блог с динамическими маршрутами

Заголовок раздела «🏗️ Полный пример: блог с динамическими маршрутами»
lib/blog.ts
export interface Post {
slug: string;
title: string;
excerpt: string;
content: string;
tags: string[];
publishedAt: string;
author: { name: string; avatar: string };
coverImage: string;
}
// app/blog/page.tsx — список постов
import Link from 'next/link';
import { getPosts } from '@/lib/blog';
export default async function BlogPage() {
const posts = await getPosts();
return (
<div className="max-w-4xl mx-auto py-12 px-4">
<h1 className="text-4xl font-bold mb-8">Блог</h1>
<div className="grid gap-8">
{posts.map(post => (
<article key={post.slug}>
<Link href={`/blog/${post.slug}`}>
<h2 className="text-2xl font-bold hover:text-blue-600">
{post.title}
</h2>
</Link>
<p className="text-gray-600 mt-2">{post.excerpt}</p>
<div className="flex gap-2 mt-3">
{post.tags.map(tag => (
// Тег → тегированная страница
<Link
key={tag}
href={`/blog/tag/${tag}`}
className="text-sm bg-gray-100 px-3 py-1 rounded-full hover:bg-gray-200"
>
#{tag}
</Link>
))}
</div>
</article>
))}
</div>
</div>
);
}
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getPost, getPosts } from '@/lib/blog';
// Статическая генерация всех постов
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map(post => ({ slug: post.slug }));
}
// Динамическая metadata
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
if (!post) return { title: 'Не найдено' };
return {
title: post.title,
description: post.excerpt,
openGraph: { images: [post.coverImage] },
};
}
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
return (
<article className="max-w-3xl mx-auto py-12 px-4 prose">
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}