Как делать11 октября 2026 г.10 мин чтения
Next.jsШаг 2 из 7

Next.js App Router: страницы, макеты и маршруты

Коротко

В App Router маршрут — это папка внутри app/: файл page.tsx делает её адресом, layout.tsx задаёт общий каркас, который не перерисовывается при переходах, а папка в квадратных скобках ([slug]) создаёт динамический маршрут. Переходы делают через <Link> — Next.js заранее загружает страницу и меняет только изменившуюся часть. Файлы loading, error и not-found задают состояния загрузки, ошибки и «не найдено», а metadata и generateMetadata — title и description каждой страницы.

Пошаговый урокУровень: СреднийNext.jsПрактика: ~1,5–3 ч

После материала вы сможете

  • собрать сайт из страниц и вложенных макетов на 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 г.

Содержание
  1. 01Что даёт Next.js поверх React
  2. 02Маршрут = папка: page и layout
  3. 03Вложенные макеты и навигация через Link
  4. 04Динамический маршрут [slug] и params
  5. 05Переход из кода: useRouter
  6. 06loading, error и not-found
  7. 07Метаданные и SEO страницы
  8. 08Частые ошибки и упражнения

Что даёт 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, и часть поведения ниже (например, статус ответа для неизвестного адреса) зависит именно от них.

Terminal
npx create-next-app@latest kite-studio --yes
cd kite-studio
npm run dev
Создание проекта с настройками по умолчанию; dev-сервер откроется на http://localhost:3000

Практика урока — сайт студии 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

Адрес
—
Роль
Обычные модули, не маршруты
Структура проекта Kite Studio

Начнём с данных. В реальном проекте услуги пришли бы из CMS или базы; здесь это массив, чтобы урок был про маршрутизацию, а не про загрузку данных:

lib/services.ts
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);
}
Данные услуг: список и поиск по slug

Корневой макет обязателен и должен содержать теги <html> и <body>. Он оборачивает все страницы сайта: сюда идут шапка, подвал и метаданные по умолчанию. Тип LayoutProps<"/"> не нужно импортировать — Next.js генерирует его глобально при next dev, next build или next typegen и выводит из структуры папок, какие пропсы получит макет.

app/layout.tsx
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:

app/page.tsx
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>
  );
}
app/contacts/page.tsx
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 перерисовывает только страницу: шапка и боковое меню остаются на месте и не теряют состояние.

  1. 01app/layout.tsxhtml, body, шапка с навигацией — общие для всего сайта
  2. 02app/services/layout.tsxбоковое меню услуг — только для раздела /services
  3. 03app/services/[slug]/page.tsxсодержимое конкретной услуги — меняется при переходе
Что видит пользователь на /services/websites: три слоя, вложенных друг в друга.
app/services/layout.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>
  );
}
Макет раздела: Server Component, данные берёт напрямую

<Link> из next/link расширяет обычный <a>: ссылка остаётся настоящей ссылкой (её можно открыть в новой вкладке, её видят поисковики), но при клике Next.js не перезагружает страницу, а подменяет только изменившиеся сегменты. Кроме того, <Link> заранее загружает (prefetch) маршрут, когда ссылка попадает в область видимости, поэтому к моменту клика данные часто уже в браузере. Обычный <a> между своими страницами — частая ошибка: каждый клик превращается в полную перезагрузку.

Чтобы подсветить активный пункт меню, нужен текущий адрес. Его читает хук usePathname, а хуки работают только в клиентских компонентах — поэтому навигация вынесена в отдельный файл с директивой "use client". Макет при этом остаётся серверным: клиентским становится только маленький компонент ссылок.

app/nav-links.tsx
"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.

app/services/[slug]/page.tsx
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>
  );
}
Страница услуги: статическая генерация, 404 и метаданные из данных
  1. generateStaticParams

    Возвращает список значений slug, которые известны заранее. Для них Next.js строит HTML при сборке. Функция должна всегда возвращать массив, даже пустой.

  2. generateMetadata

    Получает те же params и возвращает title и description конкретной услуги. В <head> попадёт Websites | Kite Studio — шаблон из корневого макета подставит название.

  3. notFound()

    Если услуги с таким slug нет, функция из next/navigation прерывает рендер и показывает ближайший not-found.tsx. TypeScript понимает, что после вызова код не продолжается, поэтому дальше service уже не undefined.

  4. PageProps<"/services/[slug]">

    Глобальный тип-помощник: по адресу маршрута выводит форму params — здесь Promise<{ slug: string }>. Импортировать его не нужно.

Результат видно в выводе сборки. Три известные услуги отмечены ○ — это готовый HTML. Строка ◐ /services/[slug] — «оболочка» для адресов, которых не было в generateStaticParams: с включёнными Cache Components Next.js готовит для них статический каркас, а содержимое дорендеривает по первому запросу.

Terminal
$ 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 content
Реальный вывод next build (Next.js 16.4, Windows 11), промежуточные строки прогресса убраны

Переход из кода: useRouter

Документация Next.js рекомендует <Link> везде, где пользователь кликает по ссылке. useRouter нужен, когда переход — следствие другого действия: выбора в списке, успешной отправки формы, нажатия клавиши. Хук импортируется из next/navigation (не из next/router — это API старого Pages Router) и работает только в клиентских компонентах.

app/services/service-picker.tsx
"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 }. Всё, что передаётся из серверного компонента в клиентский через пропсы, сериализуется и уходит в браузер, поэтому лишние поля (а тем более секреты) туда не кладут.

app/services/page.tsx
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 на уровень выше.

app/services/loading.tsx
export default function Loading() {
  return <p aria-busy="true">Loading…</p>;
}
Запасной интерфейс, пока страница раздела загружается

loading.tsx автоматически оборачивает страницу в <Suspense>: шапка и меню остаются интерактивными, а вместо содержимого сразу видна заглушка. У нас все услуги собраны при сборке, поэтому заглушку почти не увидеть. Она начинает работать, когда страница рендерится по запросу — например, загружает данные из API. Для динамических маршрутов документация рекомендует добавлять loading.tsx: тогда каркас и заглушку можно загрузить заранее.

app/services/error.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 и состояние.

app/services/[slug]/not-found.tsx
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>
  );
}
Показывается, когда страница вызывает notFound()

С not-found есть тонкость, которую мы увидели при проверке. Пока ответ не начал передаваться потоком (streaming), notFound() даёт статус 404. Но для адреса, которого нет в generateStaticParams, Next.js с Cache Components сначала отдаёт готовый каркас — и статус 200 уже отправлен. Страница «не найдено» всё равно показывается, а Next.js добавляет <meta name="robots" content="noindex">, чтобы поисковик её не индексировал. Следующие запросы на тот же адрес уже получают 404 из кэша:

Terminal
$ 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
Реальный вывод без proxy.ts: первый запрос 200 + noindex, повторный 404

Для поисковиков noindex достаточно, но если статус важен (аналитика, мониторинг, требования заказчика), документация советует проверить slug до начала рендера — в Proxy. Это файл proxy.ts в корне проекта; до Next.js 16 он назывался middleware.ts. Проверка здесь быстрая — поиск в массиве; тяжёлые запросы к базе в Proxy не делают.

proxy.ts
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",
};
Неизвестная услуга → 404 с первого запроса. Matcher ограничивает Proxy адресами /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 каждого адреса.

Terminal
$ 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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.