Главная / Статьи

Миграция с next/router: параметры, поверхностные URL и ошибки 404 в App Router

Как заменить Next/router: как работают параметры, useParams, поверхностные обновления URL, программное навигирование и настоящие страницы 404 в App Router Next.js.

Издатель: REACTAPP.TOP6 мин чтения1340 слов

Источники: Next.js routing: the four next/router habits I had to unlearn

Разработчики, привыкшие к Pages Router, часто продолжают использовать import { useRouter } from 'next/router'. В проектах с App Router такой импорт вводит в заблуждение: модуль действительно существует, он может даже пройти проверку типов, однако router.query возвращает пустой объект, и ошибка остаётся незаметной. Навигация в App Router (стандартный подход с версии Next.js 13) реализована в модуле next/navigation, а некоторые характерные для Pages Router паттерны просто больше не существуют. В этом руководстве рассматриваются четыре изменения, с которыми вы столкнётесь при переносе небольшого блога на App Router, с примерами кода, работающими в версиях Next.js 16.3.1 и React 19 на момент написания.

Динамические параметры поступают в виде свойства, а не через хук

В Pages Router обычным подходом было сделать страницу клиентским компонентом и читать router.query.id. В App Router страница является серверным компонентом, если только вы не указали иначе, причем Next.js передаёт ей свойство params. Начиная с Next.js 15, params представляет собой Promise, поэтому компонент должен быть async и использовать await, чтобы получить значение.

Страница импортирует функцию notFound, чтобы можно было прервать работу, когда идентификатор не соответствует какой-либо записи:

import { notFound } from 'next/navigation'

Приведённый ниже компонент находит запись во внутренней памятной структуре и вызывает notFound() для неизвестных идентификаторов.

const POSTS: Record<string, { title: string; body: string }> = {
  'hello-world': { title: 'Hello World', body: 'The first post.' },
}export default async function BlogPost({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const post = POSTS[id]
  if (!post) notFound()  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </article>
  )
}

Здесь нет гака и директивы 'use client', поэтому поиск выполняется на сервере ещё до отправки любого HTML. Запрос к /blog/hello-world в производственной версии возвращает заголовок и содержимое поста с кодом состояния 200. Игнорирование строки await params — самая распространённая ошибка при миграции на Next.js 15 и новее версии: значение, которое вы разбираете, является обещанием, а не идентификатором.

Чтение сегмента на клиенте с помощью useParams

Рендеринг сначала на сервере не означает, что все компоненты выполняются там. Форма для комментариев нуждается в идентификаторе поста в браузере. Для этого используется функция useParams из пакета next/navigation вместо router.query. Файл начинается с соответствующей директивы:

'use client'

Затем компонент считывает этот сегмент. Передача аргумента типа в функцию useParams позволяет получить результат с указанием типа:

import { useParams } from 'next/navigation'export default function CommentBox() {
  const { id } = useParams<{ id: string }>()
  return <form data-post={id}>Commenting on {id}</form>
}

В отличие от версии Pages Router здесь никогда не возвращается null: сегмент разрешается до того, как отобразится любой клиентский компонент ниже. Форма, отображаемая на странице поста, сразу при первом рендеринге содержит параметр data-post="hello-world", поэтому не требуется состояние загрузки.

Поверхностное маршрутизирование превращается в обычный вызов History API

Pages Router позволяет использовать команду router.push(url, as, { shallow: true }) для изменения URL без повторной загрузки данных. У App Router нет опции shallow. Чтобы обновить URL для таких параметров, как порядок сортировки, фильтры или активная вкладка, не запуская заново серверные компоненты, необходимо вручную вызвать window.history.pushState и считать значение обратно с помощью useSearchParams. Как и раньше, файл представляет собой клиентский компонент:

'use client'

Контроллер копирует текущие параметры, устанавливает значение для sort и добавляет новую строку запроса; в качестве альтернативы используется параметр newest:

import { useSearchParams } from 'next/navigation'export default function SortControls() {
  const searchParams = useSearchParams()
  const active = searchParams.get('sort') ?? 'newest'  function setSort(sort: string) {
    const next = new URLSearchParams(searchParams.toString())
    next.set('sort', sort)
    window.history.pushState(null, '', `?${next.toString()}`)
  }  return (
    <div>
      <button aria-pressed={active === 'newest'} onClick={() => setSort('newest')}>
        Newest
      </button>
      <button aria-pressed={active === 'top'} onClick={() => setSort('top')}>
        Top
      </button>
    </div>
  )
}

Next.js синхронизирует вызовы встроенных функций pushState и replaceState с его маршрутизатором, поэтому компонент useSearchParams сразу получает новые значения, и кнопки обновляются без необходимости отправки запроса на сервер. Существуют две важные особенности, о которых стоит знать:

  • Компонент, использующий useSearchParams, должен находиться внутри тега <Suspense>. Без него страница будет отрисовываться клиентским способом до ближайшего такого тега вместо статической отрисовки, и результаты сборки сообщат об этом.
  • pushState добавляет новую запись в историю переходов после каждого клика. Если последовательные изменения порядка элементов вызывают неудобства у пользователей при нажатии на кнопку «Назад», лучше использовать replaceState.

Программное навигирование по-прежнему использует useRouter из нового пути

Когда навигация должна происходить через роутер, например для возврата пользователя на главную страницу после отправки формы, useRouter остается соответствующим инструментом. Изменяется лишь путь импорта. Также требуется соответствующая директива:

'use client'

Сам вызов остается неизменным:

import { useRouter } from 'next/navigation'export default function GoHome() {
  const router = useRouter()
  return <button onClick={() => router.push('/')}>Home</button>
}

router.push('/') ведет себя как раньше; проблема заключается именно в строке импорта.

Персонализированные страницы 404 — это файл, отправляющий настоящий статус 404

Старая конвенция с файлом pages/404.js заменяется файлом app/not-found.tsx, который отображается каждый раз, когда вызывается функция notFound(), включая случаи вызова из серверного компонента, описанного в первом разделе. Пágina содержит ссылку на главную страницу, поэтому в ней импортируется компонент Link:

import Link from 'next/link'

Сам компонент представляет собой обычный маркап:

export default function NotFound() {
  return (
    <main>
      <h1>404 — no such post</h1>
      <Link href="/">Back to the index</Link>
    </main>
  )
}

Код состояния имеет большое значение: мягкий код 404, страница с признаками ошибки, предоставляемая с кодом 200, может негативно повлиять на индексацию поисковиками, поскольку роботы считают её настоящим контентом. Запрос к отсутствующей записи, например /blog/nope, возвращает HTTP 404 вместе с сообщением «такой записи нет». Метод notFound() устанавливает именно этот код состояния, поэтому то, что видят роботы, совпадает с тем, что видят пользователи.

Пусть результаты сборки подтвердят процесс миграции

После завершения миграции таблица маршрутов, выводимая командой next build, — это быстрейший способ проверки результата:

Route (app)
┌ ○ /
├ ○ /_not-found
└ ƒ /blog/[id]

Маркер ƒ указывает на то, что путь /blog/[id] генерируется динамически, поскольку он использует параметры, в то время как маркер ○ обозначает статические страницы индекса и маршруты, не найденные при запросе. Ни один фрагмент кода не использует next/router, router.query или параметр shallow: true. Чтобы узнать, что выполняется где, ознакомьтесь с инструкцией по определению границы между серверной и клиентской частью в странице App Router.

Основные выводы

  • Сначала ищите в кодовой базе строки from 'next/router'; каждая такая находка соответствует одному из этих четырех вариантов изменений.
  • Динамические элементы пути читаются в серверных компонентах из свойства params, а в клиентских компонентах — с помощью функции useParams.
  • Замените shallow: true на history.pushState или replaceState вместе с useSearchParams, помещённых внутрь тега <Suspense>.
  • Для реальных навигаций сохраните использование useRouter, импортируемого из next/navigation.
  • Используйте файл app/not-found.tsx с функцией notFound(), чтобы получить настоящий статус 404 вместо упрощённой версии.