← Все статьи
4 августа 20268 минBuilding in publicмонорепо · next.js app router · route groups · supabase rls · multi-product · building-in-public

Монорепо на Next.js: три продукта в одном приложении

Как держать несколько продуктов в одном Next.js App Router через route groups вместо Turborepo multi-app. Изоляция БД, auth, деплой и реальные грабли.

Степан Милахин

Почему не Turborepo multi-app и не три репозитория

Когда у тебя появляется второй продукт, первый рефлекс: создать отдельный репозиторий. Или, если начитался статей, развернуть Turborepo и сделать apps/product-a, apps/product-b. Оба варианта звучат чисто. Оба создают проблемы, которые ты не чувствуешь до третьего продукта.

Turborepo multi-app это буквально несколько Next.js-приложений под одной крышей. Каждое приложение = отдельный Vercel-проект, отдельный домен, отдельный набор env-переменных, отдельный CI/CD пайплайн. Добавить четвёртый продукт означает поднять ещё один Vercel-проект, настроить ещё один SSL, прокинуть ещё один набор секретов. Инфраструктурный оверхед растёт линейно с каждым продуктом.

Route groups в Next.js App Router дают другой подход: все продукты живут в одном приложении. Один домен, один Vercel-проект, один деплой, один набор env-переменных. Добавить новый продукт = создать папку app/(new-product)/. Zero infrastructure overhead.

Я пришёл к этому не из архитектурных соображений, а из экономических. У меня студия, три продукта: M.Sight для агентств, M.Flow для бизнес-клиентов, лендинг. Один клиент-дилер приносит столько же, сколько 30-50 мелких салонов. Держать ради этого три инфраструктуры, три деплоя, три набора мониторинга: смысла ноль. «Проекты со временем будут расти, делать всё дважды смысла нет», решил я, когда сливал второй продукт в общий репо.

Когда выбирать какой подход? Вот простое дерево решений. Route groups подходят когда продукты делят auth и инфру, но не бизнес-логику. Turborepo multi-app нужен когда продукты на разных фреймворках или их делают разные команды с разными релизными циклами. Отдельные репо, когда ownership полностью независим и команды вообще не пересекаются. Если ты соло-фаундер или маленькая команда с 2-5 продуктами на одном стеке, route groups закрывают задачу без лишней сложности.

Структура: route groups как пакеты внутри одного приложения

В App Router скобки в имени папки создают route group: (admin), (flow), (dashboard). Скобки не попадают в URL. app/(admin)/admin/page.tsx отвечает за /admin, app/(flow)/flow/page.tsx за /flow. Это формальность из документации, но при правильном использовании каждый route group становится отдельным «пакетом» со своим layout, своими компонентами и своей бизнес-логикой.

Вот как выглядит структура, когда три продукта живут в одном Next.js:

app/
  (admin)/admin/        # M.Sight, панель агентства
  (dashboard)/          # M.Sight, дашборд клиента
  (owner)/owner/        # платформенная панель
  (flow)/flow/          # M.Flow, приложение для бизнеса
    login/
    register/
    onboarding/
    (workspace)/        # authed зона с sidebar
  api/
    admin/              # API для M.Sight
    flow/               # API для M.Flow

Правило изоляции жёсткое: код M.Flow живёт в app/(flow)/, lib/flow/, app/api/flow/. Код M.Sight никогда не импортирует из lib/flow/ и наоборот. Шарить можно design tokens, утилиты инфраструктуры, Supabase-клиент. Шарить бизнес-логику нельзя. Как только один продукт начинает зависеть от бизнес-правил другого, изоляция рассыпается и ты получаешь один большой запутанный продукт вместо трёх чистых.

Для организации импортов хватает TypeScript path aliases: @/lib/flow/..., @/components/.... Не нужны npm workspace packages, не нужны package.json в каждой директории, не нужно версионирование пакетов. Это на порядок проще чем Turborepo и достаточно пока у тебя до 3-5 продуктов.

Одна вещь, которую стоит знать заранее: если route groups используют разные root layouts (а скорее всего используют, потому что у каждого продукта свой sidebar, своя навигация), переход между ними вызывает full page reload, а не client-side transition. Для multi-product это нормально, потому что пользователь редко прыгает между продуктами. Но если тебе нужен плавный переход, придётся делать единый root layout на все продукты и уже внутри переключать навигацию.

Изоляция на уровне базы данных: префиксы таблиц + раздельный RLS

Изоляция на уровне кода это полдела. Если два продукта лезут в одну базу без чётких границ, один кривой запрос может вернуть данные не того продукта. В моём случае один Supabase-проект обслуживает всё: M.Sight хранит clients, campaigns, campaign_metrics, а M.Flow использует mf_tenants, mf_leads, mf_campaigns. Префикс mf_ на каждой таблице M.Flow. Ноль коллизий имён, единая миграционная цепочка.

Но префикс это конвенция, а не защита. Защита это RLS. И тут интересный момент: два продукта используют две разные модели изоляции в одной базе. M.Sight скоупит данные по user_id и agency_id напрямую. M.Flow работает через membership: есть таблица mf_tenant_members, и SECURITY DEFINER функция mf_current_tenant_ids() возвращает список тенантов текущего пользователя. Все RLS-политики M.Flow проверяют tenant_id IN (SELECT mf_current_tenant_ids()). Подробнее про tenant-scoped RLS и типичные ловушки я разбирал в гайде по multi-tenant SaaS на Supabase.

Один Supabase Auth на всех. Пользователь логинится один раз, а система по его user_id и наличию записей в clients или mf_tenant_members понимает, к какому продукту он относится. Миграции идут одной цепочкой: файлы в supabase/migrations/ пронумерованы от 001 до 097+. M.Flow-таблицы создались миграцией 062, с тех пор обе ветки растут вместе.

Почему не отдельные PostgreSQL-схемы? Supabase RLS и PostgREST лучше всего работают с public schema. Класть одну часть таблиц в public, другую в flow значит бороться с PostgREST-роутингом, дублировать настройки RLS, усложнять миграции. Префикс в имени таблицы проще, предсказуемее и ломается реже.

Главный риск этой схемы: один баг в RLS-политике может протечь данными между продуктами. Это не теоретическая угроза. Контрмера: тесты на каждую RLS-политику, где тест проверяет что пользователь продукта A не видит данные продукта B, даже если знает ID записи.

OAuth и auth: одно Meta-приложение на все продукты (и почему два это ад)

Когда M.Flow переехал в монорепо, у нас стало два Meta-приложения: одно для M.Sight, другое для M.Flow. Два App ID, два App Secret, два набора env-переменных. Звучит логично: продукты изолированы, OAuth изолирован.

На практике это превратилось в кошмар. Открываешь Facebook for Developers и не помнишь, в какой вкладке какое приложение. Документация раздваивается. При проверке токена клиента упираешься в истёкший access token и начинаешь разбираться: это в каком из двух приложений? «Боль настолько постоянная, что мы тратим часы каждую сессию пытаясь понять архитектуру вместо того чтобы делать дело.» Решение об объединении в одно Meta-приложение приняли за 30 секунд. Env-переменные M.Flow Meta App удалили, и всё заработало.

Вывод простой: если инфра общая, не дублируй её по продуктам. Один OAuth-провайдер, один набор секретов, одна точка обновления токенов. Дублированная инфра это не изоляция, это двойная поверхность для ошибок.

Ещё один урок из auth: M.Flow начинал с magic link авторизации. Модный, «правильный» подход. PKCE ломался между браузерами, серверный callback не читал hash-фрагмент сессии, бесплатный email упирался в rate-limit. Потратили три дня на отладку. А в соседнем пакете M.Sight уже год работала авторизация через email+password. Скопировали паттерн за 10 минут. Общая кодовая база позволяет мгновенно переиспользовать проверенное решение вместо того чтобы изобретать новое.

Отдельная боль: один middleware.ts на все продукты. Middleware в Next.js один на приложение, и он превращается в роутер роутера: if (path.startsWith('/flow')) - одна auth-логика для tenant membership, if (path.startsWith('/admin')) - другая для agency. С ростом числа продуктов это спагетти, но альтернативы на уровне фреймворка пока нет. Держим чистоту за счёт выноса каждой ветки в отдельную функцию.

Поглощение продукта: как снести 12 790 строк за один спринт

Одна из вещей, которую не покрывает ни одна статья про монорепо: что делать, когда один продукт поглощает другой.

У меня был продукт M.Sight Business, отдельный инструмент для аудита таргетологов. Свои 13 таблиц, 16 страниц, 20 API-эндпоинтов, 14 компонентов. В какой-то момент стало ясно, что его функционал логичнее живёт внутри M.Flow как модуль «Реклама». Вместо двух продуктов, один из которых дублирует другой, получается один продукт с расширенными возможностями. По этой же логике мы в итоге перевели всех клиентов на собственный движок: продукт, которым пользуешься сам, развивается быстрее чем тот, который делаешь «для кого-то».

В route-group монорепо это атомарная операция. Снёс папку app/(business)/. Написал SQL-миграцию с DROP TABLE на 13 таблиц. Прогнал tsc для проверки: 0 type-errors. Минус 12 790 строк кода за один спринт. В multi-repo эта же операция превращается в миграцию данных между проектами, координацию двух CI/CD пайплайнов, параллельный деплой с feature flags.

Ещё один пример адаптации: клиент Truck Auto вскрыл ограничение исходного дизайна M.Flow. Продукт проектировался под бьюти-салоны (чек 18-240к ₸, цикл дни), а Truck Auto это грузовой дилер Hyundai (чек 14-31 млн ₸, цикл недели-месяцы, лизинг через банк). Понадобилось расширить воронку лизинг-стадией, глубокую квалификацию AI-ассистента под 7 параметров, приоритизацию по объёму. В монорепо это добавляется как расширение jsonb-конфига тенанта, без форка кода. Общая кодовая база позволяет одному продукту мгновенно наследовать инфру другого.

Деплой, env-переменные и observability: боль одного Vercel-проекта

31 cron-задача в vercel.json обслуживает все продукты из одного Vercel-проекта. Синки рекламных данных, алерты по бюджетам, утренние Telegram-брифы, еженедельные отчёты. CI/CD blast radius максимальный: сломанный тест или type error в продукте A блокирует деплой продукта B. Vercel деплоит весь проект целиком, нет нативного «деплой только /flow/*».

Env-переменные превращаются в ад при 3+ продуктах. META_ADS_APP_ID vs MFLOW_META_ADS_APP_ID, одинаковые по смыслу но разные по продукту. .env файл становится нечитаемым, а ошибка в переменной убивает не тот продукт. Решение: именование через namespace (MSIGHT_, MFLOW_) для продукт-специфичных переменных + общие без префикса для инфраструктурных.

Observability это отдельная слепая зона. Ошибки /api/flow/ и /api/admin/ летят в один поток логов Vercel. Нужна ручная фильтрация, которую никто не настраивает до первого инцидента. Для разделения я построил собственный командный центр с heartbeat-мониторингом: таблица studio_heartbeats в Supabase, RPC для пинга, визуализация зелёный/жёлтый/красный по каждому сервису. Самодельно, но работает, потому что Vercel из коробки не различает продукты внутри одного проекта.

Bundle size растёт нелинейно: каждый продукт тянет свои зависимости, tree-shaking не всегда отсекает чужой код. В serverless это прямо влияет на cold start. Один тяжёлый продукт с charts и 3D замедляет другой. Мониторинг через next-bundle-analyzer по route group помогает ловить рост до того как он станет проблемой.

Когда монорепо — premature optimization

Не добавляй сложность монорепо пока не почувствуешь боль code-sharing. Если копируешь типы между двумя проектами, время задуматься. Если нет, не нужен. 73% SaaS-стартапов меняют архитектуру репозитория в первые 18 месяцев. Ошибка на $50K не в том чтобы выбрать неправильно, а в том чтобы оптимизировать под проблемы, которых ещё нет.

PR cycle time в монорепо 19 часов vs 2 часа в polyrepo (данные по 320 scrum-командам). Для соло-фаундера это неважно, но при росте команды один PR может затрагивать файлы трёх продуктов и требовать трёх ревьюеров. Монорепо размывает ownership: сам репозиторий говорит «это один проект», и люди начинают менять чужой код потому что он рядом.

Но в 2026 году появился новый фактор: AI-агенты. Claude Code, Cursor, Copilot Workspace видят весь код, предлагают atomic changes через 20 файлов в одном PR, ловят downstream impacts. Монорепо стало идеальной средой для AI-агентов в продуктовой разработке: агент это второй потребитель структуры репозитория, оптимизирующий под полный контекст и связанный граф зависимостей. В моём случае AI-ассистент видит и M.Sight, и M.Flow, и инфраструктуру, и может предложить изменение, которое затрагивает три слоя, не ломая изоляцию. Это уже не теоретическое преимущество, а измеримое ускорение.

Монорепо не серебряная пуля. Но для маленькой команды с 2-5 продуктами на одном стеке, route groups в Next.js закрывают 90% потребностей без Turborepo, без npm workspaces, без лишней сложности. Начни с одного продукта. Когда появится второй и ты поймаешь себя на копировании auth-логики между репо, создай route group. Это займёт день, а не месяц.

Похожие статьи
Контакты

Расскажите о задаче — посмотрим, по пути ли нам

Короткий бриф или просто «хочу обсудить». Первый созвон — бесплатно и ни к чему не обязывает: честно скажем, возьмёмся ли.