Миграция с next/router: параметры, поверхностные URL и ошибки 404 в App Router
Как заменить Next/router: как работают параметры, useParams, поверхностные обновления URL, программное навигирование и настоящие страницы 404 в App Router Next.js.
Источники: 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 вместо упрощённой версии.