Перейти к содержимому

26. Monorepo и Turborepo

🏗️ Монорепо с Turborepo — один репозиторий для всего!

Заголовок раздела «🏗️ Монорепо с Turborepo — один репозиторий для всего!»

Представь: у тебя пять проектов. Веб-приложение, мобильное API, библиотека компонентов, утилиты, документация. Как хранить? Пять отдельных репозиториев? 😩

Так жили все раньше. А теперь познакомься с монорепо — одним репозиторием для всего!


Аналогия: представь большой офисный шкаф 🗄️. В нём несколько ящиков: «Веб», «API», «UI-компоненты», «Утилиты». Каждый ящик — отдельный проект. Но шкаф один — и это монорепо!

Без монорепо — каждый проект сам по себе:

repo-web/ ← git clone 1, свои node_modules
repo-mobile/ ← git clone 2, свои node_modules
repo-api/ ← git clone 3, свои node_modules
repo-ui-lib/ ← git clone 4, свои node_modules

Проблемы:

  • Хочешь обновить кнопку? Публикуй npm-пакет, обнови версию в трёх репозиториях 😤
  • Атомарный коммит через несколько проектов — невозможен
  • Каждый репозиторий — свой CI, свои скрипты, своя конфигурация

С монорепо:

my-company/
apps/
web/ ← Next.js приложение
mobile/ ← React Native
docs/ ← Документация
packages/
ui/ ← Shared компоненты
utils/ ← Общие утилиты
config/ ← Конфиги

Один git clone, один pnpm install, и все проекты видят друг друга! 🎯

Преимущества монорепо:

  • Единый git log для всех изменений
  • Общий код без npm-публикаций
  • Атомарные коммиты («сломал UI и исправил API — один коммит»)
  • Единая система тестирования и CI/CD
  • Рефакторинг через все проекты сразу

Монорепо решает проблему хранения кода. Но появляется другая: как эффективно собирать все проекты?

Запускать npm run build в каждой папке вручную? Скрипт-оболочка? Нет! Turborepo — умная build-система от Vercel, которая:

  1. 🔄 Параллельно выполняет независимые задачи
  2. 💾 Кэширует результаты — не пересобирает то, что не менялось
  3. 🧠 Строит граф зависимостей — понимает, что собирать сначала
  4. ☁️ Remote Cache — кэш между разными машинами и CI

💡 Без Turborepo: 60 секунд на сборку. С Turborepo (с прогретым кэшем): 0.3 секунды! Это не гипербола — это реальные цифры.

Когда использовать:

  • Есть 2+ проекта, которые делят код
  • Команда разрабатывает несколько связанных продуктов
  • Хочешь ускорить CI/CD
  • Устал от «обновил кнопку — публикуй 3 npm-пакета»

Стандартная структура Turborepo-монорепо:

my-monorepo/
├── apps/
│ ├── web/ ← Next.js app
│ │ ├── app/
│ │ ├── package.json
│ │ └── next.config.mjs
│ └── docs/ ← Документация (Next.js)
│ ├── app/
│ └── package.json
├── packages/
│ ├── ui/ ← Shared UI компоненты
│ │ ├── src/
│ │ │ ├── Button.tsx
│ │ │ ├── Card.tsx
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── utils/ ← Утилиты
│ │ ├── src/
│ │ │ ├── format.ts
│ │ │ └── index.ts
│ │ └── package.json
│ ├── typescript-config/ ← Shared tsconfig
│ │ ├── base.json
│ │ ├── nextjs.json
│ │ └── package.json
│ └── eslint-config/ ← Shared ESLint конфиг
│ ├── index.js
│ └── package.json
├── pnpm-workspace.yaml
├── turbo.json
└── package.json

Ключевые правила:

  • apps/ — конечные приложения (то, что деплоится пользователям)
  • packages/ — переиспользуемые библиотеки (не деплоятся напрямую)

Turborepo работает с любым пакетным менеджером, но pnpm — стандарт де-факто. pnpm workspaces связывают все пакеты вместе через symlinks.

packages:
- 'apps/*'
- 'packages/*'

Это говорит pnpm: «смотри в папках apps/ и packages/ — там тоже пакеты!»

Корневой package.json:

{
"name": "my-monorepo",
"private": true,
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
},
"devDependencies": {
"turbo": "^2.0.0",
"prettier": "^3.0.0"
},
"packageManager": "[email protected]"
}

package.json каждого пакета:

{
"name": "@repo/ui",
"version": "0.0.1",
"private": true,
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0"
}
}

Заметь "@repo/typescript-config": "workspace:*" — это ссылка на локальный пакет в монорепо! pnpm создаёт symlink. Никаких публикаций в npm 🔗


turbo.json — мозг Turborepo. Здесь описывается граф зависимостей задач.

{
"$schema": "https://turbo.build/schema.json",
"ui": "tui",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"lint": {
"dependsOn": ["^lint"]
},
"test": {
"dependsOn": ["^build"],
"inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts"],
"outputs": ["coverage/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"typecheck": {
"dependsOn": ["^typecheck"]
}
}
}

Разберём ключевые настройки:

ПараметрЗначениеЧто значит
dependsOn: ["^build"]^ перед именемСначала выполни build у ЗАВИСИМОСТЕЙ
dependsOn: ["build"]Без ^Сначала выполни build в ЭТОМ ЖЕ пакете
outputsМассив globЧто кэшировать после выполнения
inputsМассив globПри изменении каких файлов инвалидировать кэш
cache: falseБулевоНикогда не кэшировать (для dev)
persistent: trueБулевоДолгоживущий процесс (dev-серверы)

Как работает граф зависимостей. Допустим, три пакета: ui, utils, web. web зависит от ui и utils.

Граф пакетов:
ui ─────┐
├──→ web
utils ──┘

При команде turbo run build:

1. ui:build ← запускается первым (нет зависимостей)
utils:build ← параллельно с ui:build!
2. web:build ← ТОЛЬКО после завершения ui:build и utils:build

Turborepo сам строит граф, определяет порядок и параллелизм 🧠

// Пример зависимостей в turbo.json — расширенная конфигурация
{
"tasks": {
"build": {
// ^build: сначала собери все зависимости пакета
"dependsOn": ["^build"]
},
"test": {
// Тести только после сборки (в том же пакете)
"dependsOn": ["build"]
},
"lint": {
// lint не зависит от build — можно параллельно с ним!
"dependsOn": []
},
"deploy": {
// Деплой только после build и test
"dependsOn": ["build", "test", "^deploy"]
}
}
}

Turborepo — мастер параллелизма. При turbo run lint build test:

Без Turborepo (последовательно):
[ui:lint] [ui:build] [ui:test] [utils:lint] [utils:build] [utils:test] ...
Время: ~60 секунд 🐢
С Turborepo (параллельно):
t=0s: [ui:lint] [utils:lint]
t=2s: [ui:build] [utils:build] [web:lint]
t=4s: [web:build] (ждёт ui+utils build)
t=6s: [ui:test] [utils:test] [web:test]
Время: ~8 секунд 🚀

Контролируй параллелизм флагом --concurrency:

Окно терминала
# Максимум 4 задачи одновременно
turbo run build --concurrency=4
# 50% от числа CPU
turbo run build --concurrency=50%
# Одна задача за раз (для отладки)
turbo run build --concurrency=1

Это магия Turborepo. Запусти turbo run build дважды:

Окно терминала
# Первый запуск
$ turbo run build
web:build: cache miss, executing...
web:build: 23.4s
# Второй запуск (ничего не менялось!)
$ turbo run build
web:build: cache hit, replaying output...
web:build: 0.3ms 23 секунды 0.3 миллисекунды! 🤯

Turborepo хэширует входные данные задачи:

  • Содержимое исходных файлов (inputs в turbo.json)
  • Переменные окружения
  • Зависимости пакета
  • Конфигурацию turbo.json

Если хэш совпадает — достаёт результат из кэша и воспроизводит вывод.

~/.turbo/cache/
└── c4bf9a... (хэш задачи)
├── .turbo/
│ └── turbo-build.log ← сохранённый вывод консоли
└── outputs/ ← сохранённые артефакты
└── .next/

Управление кэшем:

Окно терминала
# Принудительно пересобрать всё (игнорировать кэш)
turbo run build --force
# Dry run — покажи, что будет запущено
turbo run build --dry-run
# Dry run в JSON формате (для скриптов)
turbo run build --dry-run=json
# Посмотреть граф зависимостей
turbo run build --graph

Локальный кэш — хорошо. Но у тебя 10 разработчиков и CI. Каждый компилирует одно и то же! 😤

Remote Cache решает это: кэш в облаке, все делят его.

Окно терминала
# 1. Войти в Vercel
npx turbo login
# 2. Привязать монорепо к команде Vercel
npx turbo link
# Теперь кэш автоматически загружается и скачивается!

Что происходит:

Разработчик A: [build] → upload → ☁️ Remote Cache
Разработчик B: ☁️ Remote Cache → download → [cache hit!]
CI (GitHub): ☁️ Remote Cache → download → [cache hit!]
// turbo.json — настройка remote cache
{
"remoteCache": {
"enabled": true,
"signature": true
}
}

Self-hosted remote cache — альтернативы Vercel:

  • @turborepo/remote-cache — собственный сервер
  • Nx Cloud — аналог
  • GitHub Actions Cache — через actions/cache
.github/workflows/ci.yml
- name: Setup Turborepo Remote Cache
uses: dtinth/setup-github-actions-caching-for-turbo@v1

Главная ценность монорепо — переиспользуемый код:

packages/ui/src/Button.tsx
import * as React from 'react';
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
}
export function Button({
variant = 'primary',
size = 'md',
loading = false,
children,
disabled,
className = '',
...props
}: ButtonProps) {
const base = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2';
const variants: Record<string, string> = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500',
ghost: 'text-gray-600 hover:bg-gray-100 focus:ring-gray-500',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
};
const sizes: Record<string, string> = {
sm: 'px-3 py-1.5 text-sm gap-1.5',
md: 'px-4 py-2 text-base gap-2',
lg: 'px-6 py-3 text-lg gap-2.5',
};
return (
<button
className={`${base} ${variants[variant]} ${sizes[size]} ${className}`}
disabled={disabled || loading}
{...props}
>
{loading && (
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" className="opacity-25" />
<path fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" className="opacity-75" />
</svg>
)}
{children}
</button>
);
}
packages/ui/src/Card.tsx
import * as React from 'react';
export interface CardProps {
title?: string;
description?: string;
children?: React.ReactNode;
footer?: React.ReactNode;
className?: string;
}
export function Card({ title, description, children, footer, className = '' }: CardProps) {
return (
<div className={`rounded-xl border border-gray-200 bg-white shadow-sm overflow-hidden ${className}`}>
{(title || description) && (
<div className="p-6 pb-4">
{title && <h3 className="text-lg font-semibold text-gray-900">{title}</h3>}
{description && <p className="mt-1 text-sm text-gray-500">{description}</p>}
</div>
)}
<div className="p-6">{children}</div>
{footer && <div className="border-t border-gray-100 px-6 py-4 bg-gray-50">{footer}</div>}
</div>
);
}
// packages/ui/src/index.ts — всё из одного места
export { Button } from './Button';
export type { ButtonProps } from './Button';
export { Card } from './Card';
export type { CardProps } from './Card';
export { Badge } from './Badge';
export { Input } from './Input';
packages/ui/package.json
{
"name": "@repo/ui",
"version": "0.0.1",
"private": true,
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"react": "^19.0.0",
"@types/react": "^19.0.0"
}
}

Единые правила для всех проектов монорепо:

packages/typescript-config/base.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Default",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"incremental": false,
"isolatedModules": true,
"lib": ["es2022", "DOM", "DOM.Iterable"],
"moduleDetection": "force",
"moduleResolution": "Bundler",
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "ES2022"
}
}
// packages/typescript-config/nextjs.json — для Next.js приложений
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Next.js",
"extends": "./base.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"module": "ESNext",
"jsx": "preserve",
"allowJs": true,
"paths": {}
}
}
packages/eslint-config/index.js
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import pluginReact from 'eslint-plugin-react';
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
{
plugins: { react: pluginReact },
rules: {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
},
}
);

packages/utils/src/format.ts
/** Форматирует дату в читаемый вид */
export function formatDate(date: Date | string, locale = 'ru-RU'): string {
const d = typeof date === 'string' ? new Date(date) : date;
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(d);
}
/** Форматирует число как цену */
export function formatPrice(amount: number, currency = 'RUB'): string {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency,
maximumFractionDigits: 0,
}).format(amount);
}
/** Обрезает строку до максимальной длины */
export function truncate(str: string, maxLength: number): string {
if (str.length <= maxLength) return str;
return str.slice(0, maxLength - 3) + '...';
}
/** Slug из строки */
export function slugify(str: string): string {
return str
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
}
// packages/utils/src/cn.ts — утилита для className
export function cn(...classes: (string | undefined | null | false)[]): string {
return classes.filter(Boolean).join(' ');
}
packages/utils/src/index.ts
export * from './format';
export * from './cn';

// apps/web/package.json — добавляем зависимость
{
"name": "web",
"private": true,
"dependencies": {
"@repo/ui": "workspace:*",
"@repo/utils": "workspace:*",
"next": "^15.0.0",
"react": "^19.0.0"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"@repo/eslint-config": "workspace:*"
}
}
// apps/web/app/page.tsx — используем shared компоненты
import { Button, Card } from '@repo/ui';
import { formatDate, formatPrice } from '@repo/utils';
export default function HomePage() {
const products = [
{ id: 1, name: 'Next.js курс', price: 9900, date: new Date() },
{ id: 2, name: 'React курс', price: 7900, date: new Date() },
];
return (
<main className="container mx-auto p-8">
<h1 className="text-3xl font-bold mb-8">Наши курсы</h1>
<div className="grid grid-cols-2 gap-4">
{products.map(product => (
<Card
key={product.id}
title={product.name}
description={formatDate(product.date)}
footer={
<Button variant="primary">
Купить за {formatPrice(product.price)}
</Button>
}
/>
))}
</div>
</main>
);
}
// apps/web/next.config.mjs — транспилируй пакеты монорепо
const nextConfig = {
transpilePackages: ['@repo/ui', '@repo/utils'],
};
export default nextConfig;

💡 transpilePackages говорит Next.js: «компилируй эти пакеты вместе с приложением». Нужно, потому что пакеты экспортируют TypeScript/JSX напрямую.

// apps/web/tsconfig.json — расширяем shared конфиг
{
"extends": "@repo/typescript-config/nextjs.json",
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

В монорепо все пакеты взаимозависимы. Как управлять версиями при публичных пакетах? Changesets — стандартный инструмент:

Окно терминала
# Установка
pnpm add -D @changesets/cli -w
pnpm changeset init
# Создать changeset (описание изменений)
pnpm changeset
# Обновить версии согласно changesets
pnpm changeset version
# Опубликовать изменённые пакеты в npm
pnpm changeset publish

Пример changeset-файла:

.changeset/brave-lions-fly.md
---
"@repo/ui": minor
"@repo/utils": patch
---
Добавлены новые варианты кнопки `danger` и `ghost` (minor).
Исправлена функция formatDate для Safari (patch).
.changeset/config.json
{
"$schema": "https://unpkg.com/@changesets/[email protected]/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch"
}

💡 Для private пакетов ("private": true) changesets не нужны — они не публикуются в npm. Changesets нужны только если пакеты публичные.


Не всегда нужно запускать всё. Фильтры — мощный инструмент:

Окно терминала
# Только конкретный пакет
turbo run build --filter=web
# Все пакеты из папки apps
turbo run build --filter='./apps/*'
# Пакеты, которые изменились относительно HEAD^1
turbo run build --filter='[HEAD^1]'
# Пакеты, которые зависят от @repo/ui (и сам @repo/ui)
turbo run build --filter=...@repo/ui
# Пакеты, от которых зависит web (включая web)
turbo run build --filter=web...
# Комбинации
turbo run build --filter=web --filter=docs

Таблица синтаксиса фильтров:

ФильтрЧто выбирает
webТолько пакет web
./apps/*Все из папки apps/
...webweb + всё, от чего он зависит
web...web + всё, что зависит от него
[HEAD^1]Изменённые с последнего коммита
[main...HEAD]Изменённые с ветки main
# .github/workflows/ci.yml — только затронутые пакеты в CI
- name: Build & Test
run: turbo run build test --filter='[HEAD^1]...'

➕ Добавление нового приложения в монорепо

Заголовок раздела «➕ Добавление нового приложения в монорепо»

Добавим новое приложение admin:

Окно терминала
# Создаём Next.js приложение в нужной папке
cd apps
pnpm create next-app admin --typescript --tailwind --app
# Возвращаемся в корень и устанавливаем зависимости
cd ../..
pnpm install
// apps/admin/package.json — обновляем после создания
{
"name": "admin",
"private": true,
"scripts": {
"dev": "next dev --port 3002",
"build": "next build",
"lint": "next lint",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@repo/ui": "workspace:*",
"@repo/utils": "workspace:*",
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"@repo/eslint-config": "workspace:*",
"@types/react": "^19.0.0",
"typescript": "^5.0.0"
}
}
apps/admin/tsconfig.json
{
"extends": "@repo/typescript-config/nextjs.json",
"compilerOptions": {
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
Окно терминала
# Запускаем только новое приложение
turbo run dev --filter=admin
# Собираем только admin и его зависимости
turbo run build --filter=admin...

Turborepo автоматически подхватит новый пакет — никакой дополнительной конфигурации не нужно! Просто потому что папка apps/admin соответствует паттерну apps/* в pnpm-workspace.yaml 🎉


Turborepo монорепо — это всё вместе:

ИнструментРоль
pnpm workspacesСвязывает пакеты symlinks
turbo.jsonОписывает граф задач
packages/uiShared компоненты
packages/utilsShared утилиты
packages/typescript-configЕдиные TS настройки
Remote CacheКэш между машинами и CI
ChangesetsВерсионирование публичных пакетов

Типичный рабочий день с монорепо:

Окно терминала
# Разработка
turbo run dev --filter=web
# Проверка перед PR
turbo run lint typecheck test --filter='[HEAD^1]'
# Сборка в CI (с remote cache = молниеносно!)
turbo run build test

Теперь посмотри на интерактивный визуализатор pipeline 👇