Міграція з next/router: параметри, поверхневі URL та помилки 404 у App Router
Як замінити next/router: як працюють params, 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>. Без цього сторінка буде рендеруватися на клієнтській стороні до найближчого елемента<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(), включаючи з серверного компонента з першого розділу. Сторінка містить посилання назад на головну, тому вона імпортує компонент 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 замість „м’якого“ 404.