Route groups как границы модулей: доменные оболочки, колокация и налог полной перезагрузки
Route groups делят app/ на доменные модули, невидимые в URL: свой root layout на группу даёт маркетингу и приложению раздельные оболочки ценой полной перезагрузки при переходе. Приватные папки колокализуют код; направление зависимостей охраняет линт, а не роутер.
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 безраздельно владеет папкой своего слота. Честная оговорка о применимости: слоты добавляют реальную сложность файловой системы и тонкое поведение мягкой навигации — прежде чем потянуться к ним, спроси себя: у панелей действительно независимые жизненные циклы данных и ошибок, или просто независимое визуальное расположение?
- 01Что меняет route group, чего она не трогает и какова цена root layout на группу?
- 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.