В App Router маршрут — это папка внутри app/: файл page.tsx делает её адресом, layout.tsx задаёт общий каркас, который не перерисовывается при переходах, а папка в квадратных скобках ([slug]) создаёт динамический маршрут. Переходы делают через <Link> — Next.js заранее загружает страницу и меняет только изменившуюся часть. Файлы loading, error и not-found задают состояния загрузки, ошибки и «не найдено», а metadata и generateMetadata — title и description каждой страницы.
После материала вы сможете
- собрать сайт из страниц и вложенных макетов на App Router и объяснить, какой файл за что отвечает
- создать динамический маршрут
[slug]сgenerateStaticParams,notFound()иgenerateMetadata - настроить навигацию через
<Link>, подсветку активного пункта черезusePathnameи переход из кода черезuseRouter - добавить
loading,errorиnot-foundи проверить поcurl, какой HTTP-статус и title отдаёт каждая страница
Что знать заранее
Проверено на
Next.js 16.4React 19.3TypeScript 5.9Node.js 24 LTS (24.18)
Сверено с документацией: 11 октября 2026 г.
Содержание
Что даёт Next.js поверх React
React отвечает за компоненты и состояние, но не решает, как устроены адреса страниц, где рендерить HTML и как отдавать метаданные поисковикам. Next.js — фреймворк поверх React, который берёт это на себя. App Router — его современная система маршрутизации: маршруты задаются папками в app/, а компоненты по умолчанию выполняются на сервере (Server Components — серверные компоненты; подробно о них — в следующем уроке).
Адреса страниц
- Только React (SPA)
- Отдельная библиотека-роутер и ручная настройка
- Next.js App Router
- Папка = сегмент адреса,
page.tsx= страница
Первый HTML
- Только React (SPA)
- Пустой
<div>, разметку строит JavaScript в браузере - Next.js App Router
- Готовый HTML с сервера: при сборке или по запросу
Общий каркас
- Только React (SPA)
- Компонент-обёртка, который вы монтируете сами
- Next.js App Router
layout.tsx— сохраняет состояние между переходами
Title и description
- Только React (SPA)
- Сторонняя библиотека или ручная работа с
document.title - Next.js App Router
metadataиgenerateMetadata→ теги в<head>
Загрузка, ошибка, 404
- Только React (SPA)
- Свои состояния в каждом компоненте
- Next.js App Router
- Файлы
loading,error,not-foundдля целого сегмента
Урок проверен на Next.js 16.4 — актуальной версии на момент написания. Проект создан командой create-next-app с рекомендуемыми настройками: TypeScript, ESLint, Tailwind CSS, App Router и Cache Components. Последнее важно: в next.config.ts нового проекта уже стоят cacheComponents: true и partialPrefetching: true, и часть поведения ниже (например, статус ответа для неизвестного адреса) зависит именно от них.
npx create-next-app@latest kite-studio --yes
cd kite-studio
npm run devПрактика урока — сайт студии Kite Studio из четырёх видов страниц: главная, список услуг, страница услуги по адресу /services/<slug> и контакты. Общий заголовок с навигацией, отдельный макет раздела услуг с боковым меню, метаданные для каждой страницы и честный 404 для несуществующей услуги. Стили опущены: в проекте остаётся app/globals.css из шаблона с несколькими правилами для навигации.
Маршрут = папка: page и layout
Правило App Router: папки задают сегменты адреса, а специальные файлы внутри папки — то, что показывается для этого сегмента. Адрес становится публичным, только когда в папке есть page.tsx (или route.ts для API). Всё остальное в app/ — компоненты, утилиты, стили — можно класть рядом: наружу отдаётся только то, что возвращает page.
app/layout.tsx
- Адрес
- —
- Роль
- Корневой макет:
<html>,<body>, шапка, подвал
app/page.tsx
- Адрес
/- Роль
- Главная
app/contacts/page.tsx
- Адрес
/contacts- Роль
- Контакты
app/services/layout.tsx
- Адрес
- —
- Роль
- Макет раздела: боковое меню услуг
app/services/page.tsx
- Адрес
/services- Роль
- Список услуг
app/services/[slug]/page.tsx
- Адрес
/services/websitesи т. д.- Роль
- Страница одной услуги
app/nav-links.tsx, lib/services.ts
- Адрес
- —
- Роль
- Обычные модули, не маршруты
Начнём с данных. В реальном проекте услуги пришли бы из CMS или базы; здесь это массив, чтобы урок был про маршрутизацию, а не про загрузку данных:
export type Service = {
slug: string;
title: string;
summary: string;
details: string;
};
const services: Service[] = [
{
slug: "websites",
title: "Websites",
summary: "Landing pages and company sites.",
details: "Design, development and launch of a fast, accessible site.",
},
{
slug: "web-apps",
title: "Web apps",
summary: "Dashboards, portals and internal tools.",
details: "A product with accounts, roles and data that changes every day.",
},
{
slug: "mobile-apps",
title: "Mobile apps",
summary: "iOS and Android apps.",
details: "One codebase for both platforms and a release to the stores.",
},
];
export function getServices(): Service[] {
return services;
}
export function getService(slug: string): Service | undefined {
return services.find((service) => service.slug === slug);
}Корневой макет обязателен и должен содержать теги <html> и <body>. Он оборачивает все страницы сайта: сюда идут шапка, подвал и метаданные по умолчанию. Тип LayoutProps<"/"> не нужно импортировать — Next.js генерирует его глобально при next dev, next build или next typegen и выводит из структуры папок, какие пропсы получит макет.
import type { Metadata } from "next";
import { NavLinks } from "./nav-links";
import "./globals.css";
export const metadata: Metadata = {
title: {
template: "%s | Kite Studio",
default: "Kite Studio",
},
description: "Websites, web apps and mobile apps.",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en">
<body>
<header className="site-header">
<NavLinks />
</header>
<main>{children}</main>
<footer className="site-footer">© Kite Studio</footer>
</body>
</html>
);
}Страница — компонент, экспортированный по умолчанию из page.tsx. Главная лежит прямо в app/, контакты — в папке contacts, и папка сама становится адресом /contacts:
import Link from "next/link";
export default function HomePage() {
return (
<section>
<h1>We build websites and apps</h1>
<p>Pick a service to see what is included.</p>
<Link href="/services">All services</Link>
</section>
);
}import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Contacts",
description: "How to reach Kite Studio.",
};
export default function ContactsPage() {
return (
<section>
<h1>Contacts</h1>
<p>
Email: <a href="mailto:hello@example.com">hello@example.com</a>
</p>
</section>
);
}metadata задаёт title и description страницыВложенные макеты и навигация через Link
Макеты вкладываются так же, как папки. app/services/layout.tsx оборачивает и /services, и все /services/<slug>, а сам находится внутри корневого макета. При переходе между услугами Next.js перерисовывает только страницу: шапка и боковое меню остаются на месте и не теряют состояние.
- 01app/layout.tsxhtml, body, шапка с навигацией — общие для всего сайта
- 02app/services/layout.tsxбоковое меню услуг — только для раздела /services
- 03app/services/[slug]/page.tsxсодержимое конкретной услуги — меняется при переходе
import Link from "next/link";
import { getServices } from "@/lib/services";
export default function ServicesLayout({ children }: LayoutProps<"/services">) {
const services = getServices();
return (
<div className="services">
<aside>
<h2>Services</h2>
<ul>
{services.map((service) => (
<li key={service.slug}>
<Link href={`/services/${service.slug}`}>{service.title}</Link>
</li>
))}
</ul>
</aside>
<div>{children}</div>
</div>
);
}<Link> из next/link расширяет обычный <a>: ссылка остаётся настоящей ссылкой (её можно открыть в новой вкладке, её видят поисковики), но при клике Next.js не перезагружает страницу, а подменяет только изменившиеся сегменты. Кроме того, <Link> заранее загружает (prefetch) маршрут, когда ссылка попадает в область видимости, поэтому к моменту клика данные часто уже в браузере. Обычный <a> между своими страницами — частая ошибка: каждый клик превращается в полную перезагрузку.
Чтобы подсветить активный пункт меню, нужен текущий адрес. Его читает хук usePathname, а хуки работают только в клиентских компонентах — поэтому навигация вынесена в отдельный файл с директивой "use client". Макет при этом остаётся серверным: клиентским становится только маленький компонент ссылок.
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
const links = [
{ href: "/", label: "Home" },
{ href: "/services", label: "Services" },
{ href: "/contacts", label: "Contacts" },
];
export function NavLinks() {
const pathname = usePathname();
return (
<nav aria-label="Main">
{links.map(({ href, label }) => {
const isActive =
href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
aria-current={isActive ? "page" : undefined}
>
{label}
</Link>
);
})}
</nav>
);
}aria-current="page" — это видят и стили, и скринридерыДинамический маршрут [slug] и params
Создавать папку под каждую услугу не нужно. Папка в квадратных скобках — динамический сегмент: app/services/[slug]/page.tsx обслуживает /services/websites, /services/web-apps и любой другой адрес этого уровня. Значение сегмента приходит в пропсе params.
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getService, getServices } from "@/lib/services";
export function generateStaticParams() {
return getServices().map((service) => ({ slug: service.slug }));
}
export async function generateMetadata({
params,
}: PageProps<"/services/[slug]">): Promise<Metadata> {
const { slug } = await params;
const service = getService(slug);
if (!service) return { title: "Service not found" };
return { title: service.title, description: service.summary };
}
export default async function ServicePage({
params,
}: PageProps<"/services/[slug]">) {
const { slug } = await params;
const service = getService(slug);
if (!service) notFound();
return (
<article>
<h1>{service.title}</h1>
<p>{service.details}</p>
<Link href="/contacts">Discuss a project</Link>
</article>
);
}generateStaticParams
Возвращает список значений
slug, которые известны заранее. Для них Next.js строит HTML при сборке. Функция должна всегда возвращать массив, даже пустой.generateMetadata
Получает те же
paramsи возвращает title и description конкретной услуги. В<head>попадётWebsites | Kite Studio— шаблон из корневого макета подставит название.notFound()
Если услуги с таким slug нет, функция из
next/navigationпрерывает рендер и показывает ближайшийnot-found.tsx. TypeScript понимает, что после вызова код не продолжается, поэтому дальшеserviceуже неundefined.PageProps<"/services/[slug]">
Глобальный тип-помощник: по адресу маршрута выводит форму
params— здесьPromise<{ slug: string }>. Импортировать его не нужно.
Результат видно в выводе сборки. Три известные услуги отмечены ○ — это готовый HTML. Строка ◐ /services/[slug] — «оболочка» для адресов, которых не было в generateStaticParams: с включёнными Cache Components Next.js готовит для них статический каркас, а содержимое дорендеривает по первому запросу.
$ npm run build
▲ Next.js 16.4.0 (Turbopack)
✓ Running next.config.ts took 27ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 532ms
Running TypeScript ...
Finished TypeScript in 1258ms ...
Collecting page data using 8 workers ...
✓ Generating static pages using 8 workers (10/10) in 949ms
Finalizing page optimization ...
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /contacts
├ ○ /services
└ /services/[slug]
├ ◐ /services/[slug]
├ ○ /services/websites
├ ○ /services/web-apps
└ ○ /services/mobile-apps
ƒ Proxy (Middleware)
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed contentnext build (Next.js 16.4, Windows 11), промежуточные строки прогресса убраныПереход из кода: useRouter
Документация Next.js рекомендует <Link> везде, где пользователь кликает по ссылке. useRouter нужен, когда переход — следствие другого действия: выбора в списке, успешной отправки формы, нажатия клавиши. Хук импортируется из next/navigation (не из next/router — это API старого Pages Router) и работает только в клиентских компонентах.
"use client";
import { useRouter } from "next/navigation";
type Option = { slug: string; title: string };
export function ServicePicker({ options }: { options: Option[] }) {
const router = useRouter();
return (
<label>
Go to service{" "}
<select
defaultValue=""
onChange={(event) => router.push(`/services/${event.target.value}`)}
>
<option value="" disabled>
Choose…
</option>
{options.map((option) => (
<option key={option.slug} value={option.slug}>
{option.title}
</option>
))}
</select>
</label>
);
}Серверная страница списка передаёт в клиентский компонент только то, что ему нужно, — массив { slug, title }. Всё, что передаётся из серверного компонента в клиентский через пропсы, сериализуется и уходит в браузер, поэтому лишние поля (а тем более секреты) туда не кладут.
import type { Metadata } from "next";
import { getServices } from "@/lib/services";
import { ServicePicker } from "./service-picker";
export const metadata: Metadata = {
title: "Services",
};
export default function ServicesPage() {
const services = getServices();
return (
<section>
<h1>Services</h1>
<ServicePicker
options={services.map(({ slug, title }) => ({ slug, title }))}
/>
{services.map((service) => (
<article key={service.slug}>
<h2>{service.title}</h2>
<p>{service.summary}</p>
</article>
))}
</section>
);
}router.push(href)
- Что делает
- Клиентский переход с новой записью в истории браузера
router.replace(href)
- Что делает
- Переход без новой записи: «Назад» вернёт на страницу до текущей
router.back(), router.forward()
- Что делает
- Шаг по истории браузера
router.refresh()
- Что делает
- Перезапросить серверные компоненты текущей страницы, не теряя клиентское состояние
router.prefetch(href)
- Что делает
- Заранее загрузить маршрут, на который пользователь, вероятно, перейдёт
loading, error и not-found
Три файла задают состояния целого сегмента, без условных проверок в каждом компоненте. Next.js вкладывает их в строгом порядке: layout → template → error → loading → not-found → page. Из этого следуют два практических вывода: ошибка в странице не ломает макет своего же сегмента, а error.tsx не ловит ошибку в layout.tsx того же уровня — для этого нужен error.tsx на уровень выше.
export default function Loading() {
return <p aria-busy="true">Loading…</p>;
}loading.tsx автоматически оборачивает страницу в <Suspense>: шапка и меню остаются интерактивными, а вместо содержимого сразу видна заглушка. У нас все услуги собраны при сборке, поэтому заглушку почти не увидеть. Она начинает работать, когда страница рендерится по запросу — например, загружает данные из API. Для динамических маршрутов документация рекомендует добавлять loading.tsx: тогда каркас и заглушку можно загрузить заранее.
"use client";
import { useEffect } from "react";
export default function ServicesError({
error,
retry,
}: {
error: Error & { digest?: string };
retry: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<section>
<h2>Could not load this section</h2>
<button type="button" onClick={() => retry()}>
Try again
</button>
</section>
);
}error.tsx ловит непредвиденные исключения при рендере сегмента и показывает запасной интерфейс вместо упавшего дерева. Функция retry() (стабильна с Next.js 16.3) заново загружает и перерисовывает содержимое границы; старый reset() только сбрасывает состояние ошибки без повторной загрузки. Мы проверили поведение на временной странице, которая бросает ошибку по клику: появилось «Could not load this section», боковое меню и шапка остались на месте, а Try again вернул страницу. Ошибки в обработчиках событий и асинхронном коде граница не ловит — их обрабатывают вручную через try/catch и состояние.
import Link from "next/link";
export default function ServiceNotFound() {
return (
<section>
<h1>No such service</h1>
<Link href="/services">Back to all services</Link>
</section>
);
}С not-found есть тонкость, которую мы увидели при проверке. Пока ответ не начал передаваться потоком (streaming), notFound() даёт статус 404. Но для адреса, которого нет в generateStaticParams, Next.js с Cache Components сначала отдаёт готовый каркас — и статус 200 уже отправлен. Страница «не найдено» всё равно показывается, а Next.js добавляет <meta name="robots" content="noindex">, чтобы поисковик её не индексировал. Следующие запросы на тот же адрес уже получают 404 из кэша:
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/services/abc
200
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/services/abc
404Для поисковиков noindex достаточно, но если статус важен (аналитика, мониторинг, требования заказчика), документация советует проверить slug до начала рендера — в Proxy. Это файл proxy.ts в корне проекта; до Next.js 16 он назывался middleware.ts. Проверка здесь быстрая — поиск в массиве; тяжёлые запросы к базе в Proxy не делают.
import { NextResponse, type NextRequest } from "next/server";
import { getService } from "@/lib/services";
export function proxy(request: NextRequest) {
const slug = request.nextUrl.pathname.split("/")[2];
if (!getService(slug)) {
return NextResponse.rewrite(new URL("/404", request.url));
}
}
export const config = {
matcher: "/services/:slug",
};Метаданные и SEO страницы
Метаданные задаются двумя способами, и оба работают только в серверных компонентах. Статический объект metadata подходит, когда title известен заранее (контакты, список услуг). Функция generateMetadata нужна, когда title зависит от данных (страница услуги). Next.js сам собирает из них теги в <head>; теги charset и viewport добавляются всегда.
title.template: "%s | Kite Studio"
- Что делает
- Шаблон для дочерних сегментов:
Services→Services | Kite Studio
title.default
- Что делает
- Title для страниц без своего title; обязателен, если задан
template
title.absolute
- Что делает
- Title, который игнорирует шаблон родителя
description
- Что делает
- Наследуется дочерними страницами, пока они не зададут свой
Шаблон применяется только к дочерним сегментам. Поэтому главная, у которой нет своего title, получает title.default — просто Kite Studio, а title.template в page.tsx не действует вовсе: у страницы нет дочерних сегментов. Description наследуется: у /services нет своего, и там остаётся описание из корневого макета. Для каталога это нормально, но у страниц, которые должны собирать поисковый трафик, лучше задавать собственный.
Проверим собранный сайт без браузера: запускаем продакшен-сервер и смотрим статус и title каждого адреса.
$ npm run start &
$ for path in / /services /services/websites /contacts /services/unknown; do
curl -s -o page.html -w "%{http_code} $path " "http://localhost:3000$path"
grep -o '<title>[^<]*</title>' page.html | head -1
done
200 / <title>Kite Studio</title>
200 /services <title>Services | Kite Studio</title>
200 /services/websites <title>Websites | Kite Studio</title>
200 /contacts <title>Contacts | Kite Studio</title>
404 /services/unknown <title>404: This page could not be found.</title>npm run build: title-шаблон работает, неизвестная услуга с proxy.ts отдаёт 404Частые ошибки и упражнения
- Корневой
app/layout.tsxопределяет<html>и<body>, а<title>и<meta>задаются через Metadata API, не вручную - Между своими страницами —
<Link>, а не<a>: иначе каждый клик перезагружает страницу paramsиsearchParamsожидаются черезawait(в клиентском компоненте —use())- Хуки
usePathnameиuseRouterимпортируются изnext/navigationи живут в файлах с"use client" "use client"стоит в маленьких интерактивных компонентах, а не в макете целикомerror.tsx— клиентский компонент; ошибку в макете ловит граница уровнем выше, в корневом —global-error.tsx- У каждой индексируемой страницы свой title; для
title.templateзаданtitle.default - Неизвестный slug проверен: статус и
noindexсоответствуют ожиданиям
Дальше по траектории — серверные компоненты и загрузка данных: как получать данные из API и базы, кэшировать их в Cache Components и изменять через Server Actions. Стили для этого же проекта — в уроке о Tailwind CSS.
Как проверено5 · пройдено1 · частичносверено 11 октября 2026 г.
- запущено, работает
Проект Kite Studio: lib/services.ts, app/layout.tsx, nav-links.tsx, page.tsx, contacts, services/layout|page|service-picker|loading|error, services/[slug]/page|not-found, proxy.ts
create-next-app@16.4.0 --yes (Next.js 16.4.0, React 19.3.0, TypeScript 5.9.3, cacheComponents + partialPrefetching), next build OK, tsc --noEmit OK, eslint OK; код статьи считан генератором прямо из файлов проекта
- запущено, работает
Маршруты, title и статусы (next start + curl)
200 для /, /services, /services/websites, /contacts с title по шаблону «%s | Kite Studio»; /services/unknown с proxy.ts — 404 с первого запроса; без proxy.ts — первый запрос 200 + noindex + not-found UI, повторный 404 (вывод реальный, порт в статье заменён на 3000)
- запущено, работает
Клиентская навигация (Link, useRouter, aria-current, история)
playwright-core + headless Edge: метка в window сохранилась после Link, router.push из select, ссылки бокового меню и goBack; ошибок страницы нет
- запущено, работает
error.tsx и retry()
временная клиентская страница /services/zz-boom бросала ошибку по клику: показан error.tsx, макет раздела и шапка на месте, Try again вернул страницу; страница после проверки удалена
- запущено, работает
Упражнения: /about, ссылки prev/next, раздел блога
решения добавлены в копию проекта: next build (/about, /blog/hello, /blog/routing — ○), tsc, eslint, curl: title «About | Kite Studio», «Hello | Kite Studio», prev/next по позициям
- частично
loading.tsx
собирается и входит в оболочку ◐ /services/[slug] (найден в HTML ответа для неизвестного slug); видимое переключение заглушки на медленных данных не проверялось — в уроке все страницы статические, это сказано в тексте
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.