open atlas
↑ К треку
Next.js с нуля до senior NEXT · 02 · 01

Архитектура App Router: layout, который живёт, и файлы, у которых есть смысл

Папки — это маршруты, спецфайлы — UI: layout оборачивает и живёт между навигациями (не перерендеривается, состояние сохраняется), page — лист, loading — граница Suspense, error — клиентская граница ошибок. Плюс группы маршрутов, параллельные и перехватывающие маршруты.

NEXT Middle ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior

Команда SaaS выкатывает дашборд: сайдбар живёт в app/dashboard/layout.tsx и запрашивает подписку пользователя, чтобы показать бейдж «Free plan». Клиент апгрейдится на /dashboard/settings/billing, видит тост об успехе, переходит на /dashboard/projects — а в сайдбаре по-прежнему «Free plan». Команда сжигает на это день: винит кеширование fetch, рассыпает cache: 'no-store' по всему коду, даже добавляет timestamp в query-параметры. Ничего не помогает, потому что ничего и не запрашивается. Layout ни разу не перерендерился. В App Router layout, оборачивающий оба маршрута, сохраняется при такой навигации — React держит смонтированным тот же самый экземпляр компонента, вместе с состоянием. Заново рендерятся только сегменты ниже точки навигации. Исправление — одна строка: вызвать revalidatePath('/dashboard') в экшене апгрейда (или router.refresh()), — но эту строку находишь, только если знаешь, какие файловые конвенции перерендериваются при навигации, а какие намеренно нет.

Спецфайлы: контракт сегмента маршрута

App Router — файловый роутер, где каждая папка внутри app/ — это сегмент маршрута, а фиксированный набор имён файлов внутри неё несёт смысл. page.tsx делает сегмент публично доступным — нет page, нет URL. layout.tsx оборачивает всё, что ниже: собственный page плюс все вложенные сегменты, получая их как children. loading.tsx — синтаксический сахар для автоматической границы Suspense вокруг страницы сегмента: пока серверные компоненты страницы ещё грузят данные, Next немедленно стримит loading-UI. error.tsxграница ошибок сегмента; это обязательно клиентский компонент ('use client'), потому что перехват ошибок рендера и повтор через reset() — клиентская механика React. not-found.tsx рендерится при вызове notFound(), а template.tsx — редко нужный близнец layout, который при каждой навигации перемонтируется, а не сохраняется.

app/
├─ layout.tsx          ← root layout: <html>, <body>; wraps everything
├─ dashboard/
│  ├─ layout.tsx       ← persists across all /dashboard/* navigation
│  ├─ loading.tsx      ← Suspense fallback for the segment's page
│  ├─ error.tsx        ← 'use client' error boundary with reset()
│  ├─ page.tsx         ← /dashboard
│  └─ settings/
│     ├─ page.tsx      ← /dashboard/settings
│     └─ billing/
│        └─ page.tsx   ← /dashboard/settings/billing

Файлы вкладываются в фиксированном порядке — концептуально layout → template → error → loading → page, — поэтому граница ошибок ловит исключения страницы и её loading-состояния, но не своего собственного layout. Эта деталь кусается: ошибку, брошенную в layout.tsx, поймает только граница ошибок сегмента выше. Для ошибки корневого layout нужен global-error.tsx, которому придётся отрендерить даже собственные теги <html> и <body>, потому что он заменяет весь корень.

Компромисс: «конвенции вместо конфигурации» означают ноль конфигурационных файлов роутинга, которые могли бы разъехаться с кодом, — но и то, что поведение в коде невидимо. Ничто в месте вызова не говорит, что loading.tsx оборачивает страницу в Suspense — контракт нужно просто знать. Когда видишь два спиннера подряд — первый вопрос: не обёрнут ли один и тот же сегмент одновременно своим <Suspense> и loading.tsx.

Layout живёт; заново рендерится только то, что изменилось

Это несущее правило всей архитектуры. При клиентской навигации Next.js выполняет частичный рендеринг: на сервере заново рендерятся и стримятся новым RSC-payload только сегменты ниже общего layout. Каждый layout, общий для старого и нового URL, сохраняется — смонтированным остаётся тот же экземпляр компонента, так что его клиентское состояние (флаг свёрнутого сайдбара, позиция скролла внутри него, играющее видео) переживает навигацию нетронутым, а его серверный код заново не выполняется.

Отсюда три следствия, и каждое — production-баг для команды, которая их упустила. Первое: данные, загруженные в layout, не обновляются при навигации — тот самый протухший бейдж тарифа из Hook. Если данные layout должны обновиться после мутации, мутация обязана об этом сказать: revalidatePath в серверном экшене или router.refresh() с клиента — он заново запрашивает серверные компоненты текущего маршрута, не теряя клиентское состояние. Второе: layout не может читать searchParams — их получают только страницы, ровно потому, что сохранённый layout не перерендеривается при смене одной только query-строки, и свежие searchParams ему просто нечем доставить. Третье: layout не может передать пропсы своей странице. Они рендерятся в отдельных серверных проходах и приходят как независимые куски payload; общие данные означают fetch в обоих местах (его дедуплицирует мемоизация запросов — один реальный запрос на request), а не протаскивание пропсов.

Сценарий отказа: когда видишь протухшие данные layout, соблазн — превратить его в клиентский компонент с fetch в useEffect на смену pathname. Это работает — и тихо разрушает архитектуру: поддерево layout теряет преимущества серверного рендеринга, водопад запросов переезжает в браузер, а сайдбар теперь мигает пустым при каждой холодной загрузке. Честные исправления — revalidatePath после мутации, изменившей данные, или, если layout действительно должен перемонтироваться на каждую навигацию, template.tsx, который ровно для этого и существует.

Почему это работает

Зачем фреймворку делать отсутствие ререндера поведением по умолчанию? Потому что альтернатива хуже в масштабе. Если бы каждая навигация перерендеривала всё дерево от корня, каждый переход между страницами заново выполнял бы запросы данных корневого layout, перемонтировал провайдеры, терял любое UI-состояние в layout и отправлял RSC-payload всей страницы вместо фрагмента. Сохранение общих layout — то, что делает навигацию в App Router дешёвой: payload перехода с /dashboard/a на /dashboard/b содержит только сегмент b. Цена — сдвиг ментальной модели, о котором весь этот урок: «мой компонент не выполнился» перестаёт быть багом и становится фичей, вокруг которой проектируют.

Викторина

Layout в app/dashboard/layout.tsx запрашивает тариф пользователя и рендерит бейдж. Пользователь апгрейдится через серверный экшен на /dashboard/settings/billing, затем переходит на /dashboard/projects. Бейдж всё ещё показывает старый тариф. Почему?

Группы маршрутов, параллельные и перехватывающие маршруты

Три конвенции расширяют дерево за пределы простой вложенности. Группа маршрутов — папка в круглых скобках, например (marketing) — организует файлы, не влияя на URL: app/(marketing)/pricing/page.tsx обслуживает /pricing. Её настоящая сила — давать разным разделам разные layout на одной глубине URL: (marketing) и (app) могут иметь каждый свой layout.tsx под одним корнем. Классическая ловушка: две группы не должны разрешаться в один и тот же URL ((a)/about и (b)/about обе претендуют на /about) — это жёсткая ошибка сборки; а навигация между группами, не разделяющими layout, выполняется как полная перезагрузка страницы, а не мягкая навигация.

Параллельные маршруты рендерят несколько независимых поддеревьев в одном layout: папки с именами @analytics и @team становятся слотами, передаваемыми layout как пропсы рядом с children. У каждого слота свои loading.tsx и error.tsx, так что медленная панель аналитики стримится по собственному расписанию, а упавшая показывает собственный error-UI, не роняя страницу, — независимый стриминг и изолированные отказы как файловая конвенция. Каждому слоту стоит дать запасной default.tsx, иначе жёсткая перезагрузка на URL, где слоту нечего сматчить, отдаст 404 на всю страницу.

Перехватывающие маршруты — конвенция именования (.)photo — позволяют маршруту рендериться внутри текущего layout при мягкой навигации, сохраняя при этом собственную полную страницу для прямых заходов. Это паттерн модалки: клик по фото в ленте открывает /photo/42 как оверлей (перехвачено, лента видна, URL можно шарить); вставка /photo/42 в новую вкладку рендерит отдельную страницу фото. Перехват действует только на клиентскую навигацию — обновите модалку, и получите полную страницу; это поведение, которое вам и нужно, и то, что стоит проверить перед релизом.

// app/dashboard/layout.tsx — слоты приходят как пропсы, каждый стримится независимо
export default function DashboardLayout({
  children,
  analytics,
  team,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode; // app/dashboard/@analytics
  team: React.ReactNode;      // app/dashboard/@team
}) {
  return (
    <section>
      {children}
      <div className="panels">
        {analytics}
        {team}
      </div>
    </section>
  );
}

Компромисс: параллельные и перехватывающие маршруты кодируют по-настоящему сложный UI (независимые панели, модалки с собственным URL) в файловой системе, но кодировка непрозрачна — папки @, (.), (..) нечитаемы для того, кто не заучил конвенцию, а отладка 404 из-за отсутствующего default.tsx по одному дереву файлов мучительна. Используйте их там, где выигрыш реален (модалки, переживающие перезагрузку; дашборды с независимо падающими панелями); для простой двухколоночной страницы обычные компоненты понятнее.

Викторина

Лента использует перехватывающий маршрут: /photo/42 открывается как модалка поверх ленты. Пользователь открывает модалку, копирует URL и отправляет коллеге, который вставляет его в новую вкладку. Что увидит коллега?

Вспомните перед уходом
  1. 01
    Что делает каждый спецфайл сегмента маршрута и в каком порядке они вкладываются?
  2. 02
    Что именно происходит с layout при клиентской навигации и какие три ловушки из этого следуют?
Итог

App Router отображает папки внутри app/ в сегменты маршрутов и наделяет фиксированный набор имён файлов контрактным смыслом: page делает сегмент маршрутизируемым, layout оборачивает страницу и всё ниже, loading — автоматическая граница Suspense, стримящаяся, пока серверные компоненты страницы грузят данные, error — клиентская граница ошибок с повтором через reset, not-found обрабатывает notFound(), а template — перемонтируемый вариант layout. Вкладываются они как layout → template → error → loading → page, поэтому граница ошибок ловит сбои своей страницы, но не своего layout — те всплывают уровнем выше, а сбой корневого layout требует global-error.tsx с собственными html и body. Краеугольный камень архитектуры — частичный рендеринг: при мягкой навигации layout, общие для старого и нового URL, сохраняются как те же смонтированные экземпляры — клиентское состояние выживает, серверный код не выполняется заново, — а заново рендерятся и стримятся только сегменты ниже точки навигации. Отсюда три следствия: данные layout не обновляются при навигации (баг протухшего бейджа; лечится revalidatePath в мутирующем экшене или router.refresh, а не понижением layout до клиентского компонента), layout никогда не получает searchParams, и layout не может передать пропсы странице — оба делают fetch, а мемоизация запросов схлопывает дубли в рамках одного запроса. Группы маршрутов в скобках организуют разделы и дают разные layout на одной глубине URL, не влияя на пути, — но две группы не должны претендовать на один URL, а переход между группами — жёсткая навигация. Параллельные маршруты (папки-@слоты) рендерят независимые поддеревья со своими loading и error — независимый стриминг, изолированные отказы и обязательный default.tsx, чтобы жёсткая перезагрузка не отдала 404. Перехватывающие маршруты (префикс «(.)») рендерят маршрут как модалку в контексте при мягкой навигации, сохраняя отдельную страницу для прямых заходов — перехват никогда не действует на жёсткую загрузку, и именно это поведение шарящегося URL нужно и именно его стоит протестировать. Теперь, когда встретишь протухший бейдж в layout после мутации, знаешь: тянуться за revalidatePath в экшене, а не за cache-опцией на fetch.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.