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🔑 Доступ к параметрам: params
Заголовок раздела «🔑 Доступ к параметрам: params»В Next.js 15 params — это Promise, нужно await:
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.tsxinterface 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} />;}🌊 Catch-all Routes: […slug]
Заголовок раздела «🌊 Catch-all Routes: […slug]»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> );}🎭 Optional Catch-all: [[…slug]]
Заголовок раздела «🎭 Optional Catch-all: [[…slug]]»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: статическая генерация
Заголовок раздела «⚡ generateStaticParams: статическая генерация»generateStaticParams — аналог getStaticPaths из Pages Router. Говорит Next.js, какие пути нужно пресгенерировать во время сборки:
import { getAllPosts } from '@/lib/blog';
// Выполняется во время BUILDexport 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>;}🔧 generateStaticParams для вложенных маршрутов
Заголовок раздела «🔧 generateStaticParams для вложенных маршрутов»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.tsxexport async function generateStaticParams() { const categories = await getCategories(); return categories.map(cat => ({ category: cat.slug }));}
// app/products/[category]/[id]/page.tsxexport async function generateStaticParams({ params: { category },}: { params: { category: string };}) { // Вызывается отдельно для каждой категории! const products = await getProductsByCategory(category); return products.map(p => ({ id: p.id.toString() }));}🚫 notFound(): страница 404
Заголовок раздела «🚫 notFound(): страница 404»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 — глобальная 404export default function GlobalNotFound() { return ( <div className="text-center py-20"> <h1 className="text-6xl font-black">404</h1> <p>Страница не найдена</p> </div> );}🔀 redirect и permanentRedirect
Заголовок раздела «🔀 redirect и permanentRedirect»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 typeimport type { Route } from 'next';
function NavLink<T extends string>({ href, children }: { href: Route<T>; children: React.ReactNode;}) { return <Link href={href}>{children}</Link>;}🏗️ Полный пример: блог с динамическими маршрутами
Заголовок раздела «🏗️ Полный пример: блог с динамическими маршрутами»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.tsximport 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 }));}
// Динамическая metadataexport 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> );}