Коротко: Полное руководство по типизации асинхронных params и searchParams в Next.js 15+ с TypeScript: Server Components, Client Components, API-роуты и Zod.
В экосистеме Next.js произошел фундаментальный сдвиг в механике передачи контекста запроса. Начиная с Next.js 15, пропсы params и searchParams в App Router стали асинхронными (Promise). Код, годами работавший в Next.js 13 и 14 с синхронной деструктуризацией параметров, теперь генерирует предупреждения в рантайме и приводит к ошибкам компиляции TypeScript.
Разберем, почему архитектура фреймворка перешла к промисификации параметров, как грамотно организовать строгую типизацию динамических страниц, вложенных сегментов и API-роутов, а также как настроить переиспользуемые типовые хелперы без лишнего бойлерплейта.
Эволюция params: почему параметры стали Promise
Что изменилось между Next.js 14 и 15+
В предыдущих версиях Next.js параметры маршрута передавались в компоненты страниц (page.tsx), макетов (layout.tsx) и обработчиков API (route.ts) в виде готовых объектов:
// Next.js 14 (Устаревший синхронный подход)
interface PageProps {
params: { slug: string };
searchParams: { [key: string]: string | string[] | undefined };
}
export default function Page({ params, searchParams }: PageProps) {
return <h1>Post: {params.slug}</h1>;
}
Такая модель накладывала серьезные ограничения на серверный рендеринг: серверу требовалось полностью дождаться парсинга всех параметров и заголовков входящего запроса до того, как начать рендеринг даже статически предсказуемых частей дерева компонентов.
Превращение params и searchParams в Promise позволяет серверу Next.js и React Server Components (RSC) запускать рендеринг дерева немедленно. Компоненты, которым не нужны параметры URL, рендерятся и передаются клиенту в потоковом режиме (Streaming) без задержек. Узлы, зависящие от сегментов пути, приостанавливают свое выполнение только в момент явного разрешения промиса через await или хук use().
Где теперь возникают асинхронные параметры
Новый контракт затрагивает все ключевые точки входа App Router:
page.tsx— серверные и клиентские страницы;layout.tsx— серверные макеты сегментов;route.ts— API Route Handlers (GET,POST,PATCH,DELETE);generateMetadata— функция динамической генерации SEO-тегов.
Исключением остается generateStaticParams: эта функция сама поставляет параметры во время сборки и возвращает массив объектов.
Типизация динамических страниц (page.tsx)
Одиночные динамические сегменты ([id], [slug])
Для базового динамического роута, например app/blog/[slug]/page.tsx, проп params описывается как дженерик Promise<T>:
// app/blog/[slug]/page.tsx
interface RouteParams {
slug: string;
}
interface PageProps {
params: Promise<RouteParams>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function BlogPostPage({ params, searchParams }: PageProps) {
const { slug } = await params;
const resolvedSearchParams = await searchParams;
return (
<article>
<h1>Слаг статьи: {slug}</h1>
<p>Сортировка: {resolvedSearchParams.sort as string}</p>
</article>
);
}
Множественные и Catch-all сегменты ([...slug], [[...slug]])
Динамические сегменты могут содержать вложенные пути или быть опциональными. TypeScript должен точно отражать структуру данных:
- Множественные сегменты (
app/shop/[category]/[productId]/page.tsx):
interface ShopParams {
category: string;
productId: string;
}
interface ShopPageProps {
params: Promise<ShopParams>;
}
- Catch-all сегменты (
app/docs/[...slug]/page.tsx): Параметр всегда является массивом строк:
interface DocsParams {
slug: string[];
}
interface DocsPageProps {
params: Promise<DocsParams>;
}
- Optional Catch-all сегменты (
app/wiki/[[...slug]]/page.tsx): Сегмент может отсутствовать в корневом вызове, поэтому типизируется как опциональный:
interface WikiParams {
slug?: string[];
}
interface WikiPageProps {
params: Promise<WikiParams>;
}
Извлечение данных: Server Components vs Client Components
В Server Components доступ к значениям осуществляется стандартным оператором await:
export default async function ServerPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return <div>Entity ID: {id}</div>;
}
В Client Components ("use client") компоненты не могут быть асинхронными функциями. Для разворачивания промиса на клиенте используется хук use() из React:
'use client';
import { use } from 'react';
interface ClientPageProps {
params: Promise<{ id: string }>;
}
export default function ClientPage({ params }: ClientPageProps) {
const { id } = use(params);
return <div>Client-rendered ID: {id}</div>;
}
Рекомендация: передачу params в Client Components лучше минимизировать. Разрешайте параметры в серверном компоненте-обертке и прокидывайте в дочерний клиентский компонент уже готовые примитивные типы (id: string).
Асинхронные params в Route Handlers (route.ts)
API-роуты в Next.js получают контекст вторым аргументом обработчика. В актуальных версиях попытка типизировать второй аргумент синхронно приведет к ошибкам типизации и несовместимости с внутренними сигнатурами NextRequest.
Типизация контекста API-маршрута
Пример реализации эндпоинта app/api/users/[userId]/route.ts:
import { NextResponse, type NextRequest } from 'next/server';
interface RouteContext {
params: Promise<{ userId: string }>;
}
export async function GET(
request: NextRequest,
context: RouteContext
) {
const { userId } = await context.params;
if (!userId) {
return NextResponse.json({ error: 'Параметр userId обязателен' }, { status: 400 });
}
return NextResponse.json({
data: { id: userId, name: `User_${userId}` }
});
}
Безопасный парсинг и валидация параметров
Параметры из URL всегда приходят строками (string). Если параметр должен быть числом или UUID, его необходимо валидировать до передачи в бизнес-логику:
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ userId: string }> }
) {
const { userId } = await params;
const parsedId = Number(userId);
if (Number.isNaN(parsedId)) {
return NextResponse.json(
{ error: 'Некорректный формат идентификатора' },
{ status: 422 }
);
}
return NextResponse.json({ success: true, deletedId: parsedId });
}
Метаданные и статическая генерация: generateMetadata и generateStaticParams
Сигнатура generateMetadata
Функция формирования метаданных часто зависит от переданных сегментов пути. В ней params также разрешаются асинхронно:
import type { Metadata } from 'next';
interface ProductPageProps {
params: Promise<{ id: string }>;
}
export async function generateMetadata(
{ params }: ProductPageProps
): Promise<Metadata> {
const { id } = await params;
return {
title: `Товар #${id} | Магазин`,
description: `Подробная информация о позиции ${id}`,
};
}
Типобезопасность в generateStaticParams
generateStaticParams компилируется на этапе сборки и указывает Next.js, какие именно пути нужно сгенерировать статически (SSG).
Функция возвращает синхронный массив объектов (или промис такого массива), где ключи точно соответствуют именам динамических папок:
// app/posts/[category]/[slug]/page.tsx
interface PostParams {
category: string;
slug: string;
}
export function generateStaticParams(): PostParams[] {
return [
{ category: 'frontend', slug: 'nextjs-15-params' },
{ category: 'typescript', slug: 'advanced-generics' },
];
}
export default async function PostPage({
params,
}: {
params: Promise<PostParams>;
}) {
const { category, slug } = await params;
return (
<div>
Категория: {category}, Статья: {slug}
</div>
);
}
Хелперы и паттерны для масштабируемых проектов
Чтобы не дублировать конструкции Promise<{ ... }> по всему проекту, имеет смысл вынести универсальные типы и валидацию в слой переиспользуемых абстракций.
Создание универсального типа PageProps
Создадим глобальный хелпер для страниц и контекстов:
// types/next.ts
export type DynamicParams<T extends Record<string, string | string[]>> = Promise<T>;
export type SearchParamsRecord = Record<string, string | string[] | undefined>;
export interface AsyncPageProps<
TParams extends Record<string, string | string[]> = Record<string, string>,
TSearchParams extends SearchParamsRecord = SearchParamsRecord
> {
params: Promise<TParams>;
searchParams?: Promise<TSearchParams>;
}
export interface AsyncRouteContext<TParams extends Record<string, string | string[]>> {
params: Promise<TParams>;
}
Использование в конкретной странице:
// app/users/[id]/page.tsx
import type { AsyncPageProps } from '@/types/next';
type UserParams = { id: string };
type UserSearchParams = { tab?: string };
export default async function UserPage(
props: AsyncPageProps<UserParams, UserSearchParams>
) {
const { id } = await props.params;
const search = await props.searchParams;
return <div>Профиль {id}, вкладка: {search?.tab ?? 'main'}</div>;
}
Валидация входных параметров через Zod
Типизации TypeScript на этапе сборки недостаточно: данные в строке URL могут быть повреждены клиентом. Проверяйте разрешенные параметры через парсеры схем:
import { z } from 'zod';
import { notFound } from 'next/navigation';
const RouteSchema = z.object({
id: z.coerce.number().positive(),
});
export default async function StrictPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const rawParams = await params;
const result = RouteSchema.safeParse(rawParams);
if (!result.success) {
notFound();
}
const { id } = result.data; // id гарантированно имеет тип number
return <div>ID записи: {id}</div>;
}
Частые ошибки при работе с асинхронными params
| Ошибка | Причина | Способ решения |
|---|---|---|
Синхронное обращение params.id |
Попытка чтения свойств напрямую из Promise |
Добавить await params в Server Component или use(params) в Client Component |
Рассинхронизация layout.tsx |
layout.tsx не имеет доступа к динамическим параметрам дочерних папок |
Описывать в типах layout только те сегменты, которые находятся на его уровне или выше |
Ошибка сигнатуры в route.ts |
Использование устаревшего типа { params: { id: string } } |
Обернуть тип контекста в { params: Promise<{ id: string }> } |
FAQ
Почему в Next.js параметры маршрута стали промисом, а не обычным объектом?
Это архитектурное изменение направлено на оптимизацию серверного рендеринга и потоковой передачи данных (Streaming). Переход к промисам позволяет серверу начинать сборку и отправку статических частей HTML-дерева до завершения парсинга динамического контекста запроса.
Как работать с асинхронными params внутри клиентских компонентов ("use client")?
Для извлечения значений промиса в Client Components используется хук React.use(): const { id } = use(params);. При этом сам компонент остается синхронной функцией.
Нужно ли оборачивать в Promise возвращаемое значение в generateStaticParams?
Функция generateStaticParams возвращает массив объектов или Promise массива объектов (Array<Record<string, string>>), описывающий все комбинации параметров, которые необходимо статически сгенерировать при сборке. Сами поля внутри элементов массива являются обычными строками.
Как теперь типизировать searchParams?
Аналогично params: searchParams передаются как Promise<{ [key: string]: string | string[] | undefined }>. Перед чтением значений к ним необходимо применить await в Server Components или use() в Client Components.
Как безопасно выполнить миграцию большой кодовой базы с Next.js 14 на 15+?
Официальная команда Next.js предоставляет автоматический инструмент миграции через codemod:
npx @next/codemod@canary next-async-request-api .
Скрипт автоматически находит вызовы params и searchParams в страницах, макетах и хендлерах, преобразует их в асинхронные конструкции и обновляет сигнатуры TypeScript.
Вывод
Переход на асинхронные params и searchParams в Next.js App Router делает управление контекстом запроса более предсказуемым и устраняет блокировку потокового рендеринга.
Главный принцип при работе с динамическими сегментами: любой динамический контекст страницы или API-роута обрабатывается как Promise. Описав универсальные интерфейсы один раз на уровне проекта и внедрив рантайм-валидацию через Zod, вы исключите скрытый техдолг, устраните ошибки компиляции и построите надежную архитектуру маршрутизации.




.svg.webp)





