Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Типизация динамических роутов в Next.js: как правильно работать с асинхронными params в TypeScript

Тимофей Ищенко

Коротко: Полное руководство по типизации асинхронных 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 должен точно отражать структуру данных:

  1. Множественные сегменты (app/shop/[category]/[productId]/page.tsx):
interface ShopParams {
  category: string;
  productId: string;
}

interface ShopPageProps {
  params: Promise<ShopParams>;
}
  1. Catch-all сегменты (app/docs/[...slug]/page.tsx): Параметр всегда является массивом строк:
interface DocsParams {
  slug: string[];
}

interface DocsPageProps {
  params: Promise<DocsParams>;
}
  1. 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, вы исключите скрытый техдолг, устраните ошибки компиляции и построите надежную архитектуру маршрутизации.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также