Маршрутизація в Next.js (маршрутизація на основі файлів)
Маршрутизація на основі файлів (file-based routing) у Next.js будує URL-адреси зі структури папок у директорії app. Кожен файл page.tsx визначає маршрут. Жодних конфігураційних файлів, жодних <Route> декларацій, жодних імпортів з react-router.
Теорія
TL;DR
- Назва папки = сегмент URL;
app/docs/page.tsxстає/docs [slug]у назві папки захоплює той сегмент URL вparams[...slug]захоплює 1+ сегментів як масив;[[...slug]]захоплює 0+ (опціонально)- Групи маршрутів
(name)організовують файли без зміни URL - Папка без
page.tsxне створює маршрут, вона лише організовує код
Швидкий приклад
// app/page.tsx → URL: /
export default function Home() {
return <h1>Головна</h1>
}
// app/blog/page.tsx → URL: /blog
export default function Blog() {
return <h1>Блог</h1>
}
// app/blog/[slug]/page.tsx → URL: /blog/my-first-post
export default function Post({
params
}: {
params: { slug: string }
}) {
return <h1>Пост: {params.slug}</h1> // "my-first-post"
}Next.js сканує app/ під час збірки і генерує manifest маршрутів зі структури папок. Цей manifest сервер використовує для зіставлення запитів.
Як це працює всередині
Next.js (через Turbopack або webpack) рекурсивно обходить директорію app під час збірки. Кожен page.tsx стає сегментом маршруту на основі шляху до папки. Динамічні сегменти, наприклад [slug], перетворюються на regex-патерни в manifest маршрутів (build-manifest.json). На момент запиту сервер зіставляє URL з цими патернами і витягує params.
Об'єкт params передається в компонент як prop через React Server Components, тому він доступний на сервері за замовчуванням. У клієнтських компонентах використовують useParams() з next/navigation. Це і є найчастіша помилка в код-рев'ю: серверний компонент переробляють на клієнтський і забувають оновити спосіб отримання params.
Динамічні маршрути
Квадратні дужки позначають папку як динамічний сегмент:
// app/shop/[category]/page.tsx → /shop/laptops, /shop/phones
export default async function Category({
params
}: {
params: { category: string }
}) {
const products = await getProducts(params.category)
return <h1>{params.category}</h1>
}Вкладеність працює так само. app/shop/[category]/[id]/page.tsx дає і params.category, і params.id за URL /shop/laptops/123.
Catch-all маршрути
[...slug] захоплює один або більше сегментів як масив:
// app/docs/[...slug]/page.tsx
// /docs/react → slug = ['react']
// /docs/react/hooks → slug = ['react', 'hooks']
export default function Docs({
params
}: {
params: { slug: string[] }
}) {
return <div>{params.slug.join(' / ')}</div>
}[[...slug]] (подвійні дужки) також відповідає кореневому шляху. Тобто app/docs/[[...slug]]/page.tsx обробляє /docs (порожній масив) і будь-який вкладений шлях. Різниця одна: одинарний [...slug] вимагає хоча б одного сегмента, подвійний [[...slug]] робить усі сегменти опціональними. Саме це призводить до 404 при побудові індексної сторінки документації.
Групи маршрутів
Дужки створюють папку, яка групує маршрути без додавання до URL:
app/
(marketing)/
layout.tsx <- макет для маркетингу
about/page.tsx -> /about
pricing/page.tsx -> /pricing
(platform)/
layout.tsx <- макет для платформи
dashboard/page.tsx -> /dashboard(marketing) і (platform) ніколи не з'являються в URL. Кожна група може мати власний layout.tsx, тому /about і /dashboard можуть використовувати повністю різні макети, живучи в одній директорії app/.
Паралельні маршрути
Паралельні маршрути дозволяють рендерити кілька незалежних секцій в одному макеті. Слоти визначаються через @:
app/dashboard/
layout.tsx
page.tsx
@stats/page.tsx
@activity/page.tsx// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
stats,
activity
}: {
children: React.ReactNode
stats: React.ReactNode
activity: React.ReactNode
}) {
return (
<div>
{children}
<div className="grid grid-cols-2 gap-4">
{stats}
{activity}
</div>
</div>
)
}Кожен слот завантажується і стримується незалежно. @stats може показувати стан завантаження, поки @activity вже відрендерений.
Перехоплення маршрутів
Перечоплення маршрутів (intercepting routes) дозволяє показати маршрут в іншому контексті без зміни URL. Класичний приклад: клік на фото відкриває модальне вікно, але прямий перехід за URL /photos/123 показує повну сторінку.
(.) — той самий рівень
(..) — один рівень вгору
(..)(..) — два рівні вгору
(...) — від кореня appНа IT Lead натискання на картку задачі відкриває модальне вікно з попереднім переглядом. Прямий доступ за тим самим URL показує повну сторінку задачі. Це і є перехоплення маршрутів на практиці.
Спеціальні файли
| Файл | Призначення |
|---|---|
page.tsx | UI сторінки (робить сегмент доступним як маршрут) |
layout.tsx | Спільний макет, зберігається між навігаціями |
loading.tsx | Suspense-заглушка під час завантаження даних |
error.tsx | Error boundary для сегмента |
not-found.tsx | Кастомна 404 для сегмента |
template.tsx | Як layout, але перемонтується при кожній навігації |
route.ts | API-ендпоінт (Route Handler, тільки App Router) |
Папка без page.tsx не створює маршрут. Вона лише організовує код (колокацію). Next.js реєструє сегмент тільки там, де знаходить page.tsx.
Типові помилки
Помилка 1: Звернення до params напряму в клієнтському компоненті
// НЕПРАВИЛЬНО
'use client'
export default function Page({ params }: { params: { slug: string } }) {
return <div>{params.slug}</div> // undefined на клієнті
}
// ПРАВИЛЬНО
'use client'
import { useParams } from 'next/navigation'
export default function Page() {
const params = useParams() // працює на клієнті
return <div>{params.slug as string}</div>
}params як prop працює тільки в Server Components. Клієнтські компоненти потребують useParams().
Помилка 2: Використання [...slug] замість одиночного [id]
// app/[...userId]/page.tsx
// /profile/123 → params.userId = ['profile', '123'] — захоплює занадто багато
// Виправлення: app/profile/[userId]/page.tsxCatch-all захоплює все в шляху, включаючи сегменти, які ти не планував захоплювати.
Помилка 3: Забути generateStaticParams для динамічних маршрутів
// Без цього /blog/[slug] рендериться на сервері при кожному запиті → повільний TTFB
export async function generateStaticParams() {
const posts = await getPosts()
return posts.map((post) => ({ slug: post.slug }))
}Якщо slug-и відомі під час збірки, експортуй generateStaticParams. Next.js попередньо збере ці сторінки як статичний HTML.
Помилка 4: Плутати [...slug] і [[...slug]]
[...slug] на /docs без сегментів дасть 404. [[...slug]] на /docs збігається і повертає params.slug = []. Коли кореневий маршрут теж має збігатися, потрібні подвійні дужки.
Де зустрічається в реальних проектах
- Блог Next.js:
/blog/[slug]завантажує MDX-пости з файлової системи - Документація Vercel:
/docs/[product]/[version]/apiдля мультипродуктової документації - IT Lead: перехоплення маршрутів для модального попереднього перегляду задач
- Сайти на основі CMS:
generateStaticParamsпопередньо збирає сторінки з API під час деплою - Застосунки типу Linear:
[...id]catch-all для глибоких посилань на задачі
Follow-up питання
Q: Яка різниця між [...slug] і [[...slug]]?
A: [...slug] вимагає хоча б одного сегмента. Запит на /docs без подальшого шляху дасть 404. [[...slug]] робить усі сегменти опціональними, тому /docs збігається і повертає порожній масив.
Q: Що станеться на /docs/a/b, якщо файл app/docs/[slug]/page.tsx?
A: 404. Одинарний [slug] захоплює рівно один сегмент. Для багатосегментних шляхів потрібен [...slug].
Q: Чим відрізняються params і searchParams?
A: params береться зі шляху URL (/post/123 дає { id: '123' }). searchParams береться з рядка запиту (?draft=true дає { draft: 'true' }). Обидва доступні як props у Server Components.
Q: Як попередньо зібрати динамічні маршрути як статичні сторінки?
A: Експортуй generateStaticParams(), що повертає масив об'єктів з параметрами. Next.js запускає це під час збірки і генерує статичний HTML для кожного запису.
Q: (Senior) Middleware перехоплює /[slug] і перезаписує URL. Чи params залишаться в компоненті сторінки?
A: Так. Використовуй NextResponse.rewrite(new URL('/real-path', req.url)) у middleware. Перезапис змінює, який файл обробляє запит, але params витягуються з URL-патерну відповідного файлу і залишаються незмінними.
Приклади
Базова структура маршрутів
// app/page.tsx → /
export default function Home() {
return <h1>Головна</h1>
}
// app/problems/page.tsx → /problems
export default function Problems() {
return <h1>Задачі</h1>
}
// app/problems/[id]/page.tsx → /problems/fizzbuzz
export default function Problem({
params
}: {
params: { id: string }
}) {
return <h1>Задача: {params.id}</h1>
}Назва папки стає сегментом URL. page.tsx робить його доступним. Це вся система.
Завантаження поста блогу з notFound
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
async function getPost(slug: string) {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
cache: 'force-cache' // кешується під час збірки або через ISR
})
if (!res.ok) notFound() // активує найближчий not-found.tsx
return res.json()
}
export default async function Post({
params
}: {
params: { slug: string }
}) {
const post = await getPost(params.slug)
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
)
}
// Попередньо збираємо відомі slug-и під час деплою
export async function generateStaticParams() {
const posts = await getPosts()
return posts.map((p) => ({ slug: p.slug }))
}notFound() з next/navigation активує найближчий not-found.tsx. generateStaticParams вказує Next.js, які slug-и попередньо рендерити як статичний HTML, що прибирає серверний рендеринг при кожному запиті і покращує TTFB.
Catch-all маршрут для документації з опціональним коренем
// app/docs/[[...slug]]/page.tsx
// Збігається з: /docs, /docs/react, /docs/react/hooks/useState
export default function Docs({
params
}: {
params: { slug?: string[] }
}) {
if (!params.slug || params.slug.length === 0) {
return <h1>Головна документації</h1>
}
const path = params.slug.join('/')
// /docs/react/hooks → path = "react/hooks"
return <div>Документ: /{path}</div>
}Подвійні дужки [[...slug]] роблять усі сегменти опціональними. Один файл обробляє і індексну сторінку документації, і будь-який вкладений шлях. З одинарними дужками [...slug] індексна сторінка давала б 404.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.