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

Route groups как границы модулей: доменные оболочки, колокация и налог полной перезагрузки

Route groups делят app/ на доменные модули, невидимые в URL: свой root layout на группу даёт маркетингу и приложению раздельные оболочки ценой полной перезагрузки при переходе. Приватные папки колокализуют код; направление зависимостей охраняет линт, а не роутер.

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

B2B SaaS на 40 инженеров, одно Next.js-приложение, 214 папок маршрутов лежат плоско в app/. Инцидент, который наконец заставляет говорить об архитектуре, мелкий: маркетинг просит агентство добавить аналитический скрипт и два декоративных шрифта. Инженер агентства кладёт их туда, куда кладут всё глобальное — в корневой layout — и деплоит. Скрипт и шрифты теперь грузятся и на каждом экране авторизованного дашборда; LCP дашборда деградирует на 600 мс, и дежурный полдня бисектит деплои, потому что в коде дашборда не менялось ничего. Ретро вскрывает гниль глубже: app/components/ — свалка из 200 файлов, которую трогает каждый PR; два squad-а сопровождают дублирующиеся реализации Modal, потому что ни один не смог найти чужую; новичку понадобилось три дня, чтобы понять, где рендерятся настройки биллинга. Команда режет приложение на route groups с раздельными оболочками маркетинга и продукта — и тут же напарывается на налог, о котором никто не предупредил: переход с /pricing на /login теперь делает полную перезагрузку страницы. Граница правильная; этот урок — о том, где она стоит денег.

URL — не ваша модульная система

App-роутер отображает папки в сегменты URL и тем самым тихо сцепляет организацию кода с публичной информационной архитектурой: раз /pricing и /dashboard/billing обязаны жить по этим URL, их код будто бы обязан жить по этим путям. Route groups разрывают сцепку. Папка в круглых скобках — (marketing), (app), (admin) — полностью вырезается из URL: app/(marketing)/pricing/page.tsx по-прежнему отдаёт /pricing. Это делает группы чистой организацией — ровно тем, чем должна быть граница модуля: плоское пространство из 214 папок можно раскроить на домены, закреплённые за squad-ами — по строке CODEOWNERS на группу, — без единого редиректа, и рефакторить инкрементально, потому что пространство URL ничего не замечает. Каждая группа может нести собственные layout.tsx, loading.tsx и error.tsx, так что группа — не просто папка: это область действия оболочки рендеринга, suspense-границы и error-границы одного домена.

Root layout на группу и налог перезагрузки

Самая сильная версия границы — удалить верхний app/layout.tsx и дать каждой группе собственный root layout, каждый из которых сам рендерит теги html и body. Теперь у маркетинга статичная оболочка: шрифты, аналитический скрипт, ноль клиентских провайдеров. У группы приложения — session-провайдер, сайдбар и контекст темы. В том SaaS вынос провайдеров и бандла аналитики из маркетинговой оболочки снизил first-load JS лендингов с 212 до 89 кБ — маркетинговые страницы перестали платить за дашборд, который никогда не рендерят.

Цена структурная, а не случайная: навигация между root layout не может быть клиентским переходом. Роутер патчит часть дерева ниже общего layout, а когда различается сам элемент html, патчить нечего — Next откатывается к полной навигации документа. Вместе с документом умирает всё клиентское состояние: значения контекстов, кеши в памяти, открытый тост, наполовину заполненная форма в провайдере. Переход стоит сетевой раунд-трип плюс полную гидрацию — примерно 0,5–1,5 с на среднем мобильном против ~100 мс у клиентской навигации. Правило проектирования следует напрямую: резать root layout только по швам, которые пользователь редко пересекает посреди сессии. Маркетинг → приложение пересекается один раз, при логине — здесь перезагрузку платить можно. Дашборд → настройки пересекается сорок раз в день — если эта пара попала в разные root layout, граница нарезана неправильно.

Викторина

Команда удаляет app/layout.tsx и даёт (marketing) и (app) собственные root layout. Пользователь на /pricing кликает Link на /login, который живёт в (app). Что произойдёт на самом деле?

Колокация, приватные папки и заслуженное повышение

URL создают только page.tsx и route.ts, поэтому класть не-маршрутные файлы рядом с маршрутами, которые их используют, безопасно по умолчанию. Конвенция, которую стоит закрепить, — подчёркивание: _components/, _lib/ — приватные папки, полностью исключённые из маршрутизации, громкий сигнал, что всё внутри принадлежит этому маршруту и никому больше. У дисциплины две половины. Первая: код живёт рядом с единственным маршрутом, который его использует, — форматтер цен только для чекаута живёт в (checkout)/_lib/, а не в глобальном файле утилит. Вторая: общий код обязан заслужить повышение наружу. Разумная планка: используется двумя и более доменами, имеет именованного владельца, имеет собственные тесты — тогда он переезжает в shared/ (а позже — в workspace-пакет). Выигрыш — инвариант, который ценят зрелые команды: удаление папки маршрута удаляет его UI, мёртвому коду негде прятаться. Провал при пропуске дисциплины — тот самый, из пролога: app/components/ как коммуналка на 200 файлов, где конфликтует каждый PR, ничего нельзя удалить, потому что никто не знает, кто это импортирует, а дубликаты компонентов плодятся, потому что поиск выдаёт 40 кандидатов.

Конвенции не компилируются — направление держит линт

Вот честность о механизме, которой документация сама не предложит: route groups и приватные папки меняют ноль в семантике разрешения модулей. Любой файл может импортировать любой другой через границы групп; TypeScript разрешает импорт, бандлер его отгружает, CI остаётся зелёным. Архитектурная диаграмма, где (billing) не лезет в (admin), — это пожелание, пока инструмент не уронит сборку. Стандартный инструмент — ESLint-правило границ:

// eslint.config.js — зоны import/no-restricted-paths
{
  rules: {
    'import/no-restricted-paths': ['error', {
      zones: [
        // домены не импортируют друг друга — только shared/
        { target: './app/(app)', from: './app/(marketing)' },
        { target: './app/(marketing)', from: './app/(app)' },
        { target: './app/(admin)', from: './app/(app)' },
        // shared/ не импортирует вверх ни в один домен
        { target: './src/shared', from: './app' },
      ],
    }],
  },
}

Группы импортируют из shared/; вбок друг в друга — никогда; shared/ не импортирует ни из одной группы — зависимости направлены в одну сторону, вниз. Когда зоны стоят в CI, боковой импорт — красная сборка, а не комментарий на ревью, который кто-то забудет оставить. Это единственная форма архитектуры, переживающая 40 инженеров, которые поставляют код параллельно.

Викторина

Что мешает коду в (billing) импортировать хелпер цен из (admin)/_lib?

Параллельные маршруты: композиция для дашбордов

Ещё один инструмент в наборе модульности — параллельные маршруты. Layout может объявить слоты — @revenue, @alerts, @activity — и получить каждый как проп, рендеря их рядом; каждая папка слота несёт собственные loading.tsx и error.tsx, плюс default.tsx на случай, когда слоту нечего матчить. Это даёт дашборду ровно то, что нужно экрану, собранному из независимо принадлежащих панелей: панель squad-а выручки может стримиться с опозданием или падать, не трогая панель алертов, и каждый squad безраздельно владеет папкой своего слота. Честная оговорка о применимости: слоты добавляют реальную сложность файловой системы и тонкое поведение мягкой навигации — прежде чем потянуться к ним, спроси себя: у панелей действительно независимые жизненные циклы данных и ошибок, или просто независимое визуальное расположение?

Вспомните перед уходом
  1. 01
    Что меняет route group, чего она не трогает и какова цена root layout на группу?
  2. 02
    Сформулируйте дисциплину колокации и повышения и объясните, чем на самом деле обеспечивается направление зависимостей.
Итог

App-роутер сцепляет папки с URL, пока route groups их не расцепят: папка в скобках исчезает из URL, ограничивая при этом области layout, loading- и error-границ и владение — что делает её естественной границей модуля внутри одного Next.js-приложения, рефакторимой инкрементально, потому что публичное пространство URL не сдвигается. Самый сильный разрез даёт каждому домену собственный root layout, сам рендерящий html и body: маркетинг становится статичной оболочкой со шрифтами и аналитикой без клиентских провайдеров (212 → 89 кБ first-load JS в сквозном примере), а группа приложения сохраняет контексты сессии и темы. Налог не обсуждается — переход между root layout не патчится клиентским роутером, это полная навигация документа, роняющая всё клиентское состояние и стоящая 0,5–1,5 с вместо ~100 мс, — отсюда правило размещения: раздельные root layout только по швам, пересекаемым примерно раз за сессию, как логин. Внутри группы дисциплина — колокация: _components и _lib вне маршрутизации, код живёт со своим единственным потребителем, а общий код заслуживает повышение использованием в двух и более доменах при владельце и тестах, сохраняя инвариант «удалил маршрут — удалил его UI». Ничто из этого роутер не обеспечивает — группы не меняют семантику разрешения модулей, поэтому направление зависимостей (группы импортируют shared, вбок — никогда; shared не импортирует группы) держится, только когда ESLint-зоны роняют CI на нарушениях. Параллельные маршруты завершают набор: @слоты собирают дашборд из независимо принадлежащих панелей с независимыми циклами загрузки и ошибок. Теперь, когда увидишь импорт из (billing) в (admin), ты знаешь: это не вопрос стиля — это пропущенная зона линта. А когда придёт вопрос «почему переход с /pricing на /login тормозит» — сразу понятно, где шов нарезан не там.

Практика

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

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

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

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

Примени это

Примени этот урок в реальном проекте.

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

Trademarks belong to their respective owners. Editorial reference only.