Monorepos com Turborepo: O Setup Que Realmente Escala
Como eu estruturo monorepos com Turborepo para múltiplas aplicações compartilhando pacotes. Configuração de workspace, cache de pipeline, configs TypeScript compartilhadas e os erros que cometi no primeiro mês.
Eu passei os primeiros três anos da minha carreira copiando funções utilitárias entre repositórios. Sem exagero. Eu tinha um formatDate.ts que vivia em sete projetos diferentes, cada um com bugs ligeiramente diferentes. Quando eu corrigia o problema de fuso horário em um, esquecia dos outros seis. Eventualmente um usuário na Austrália abria um ticket e eu descobria que a correção nunca chegou à produção naquele app específico.
O monorepo resolveu isso. Não porque é tendência, não porque o Google faz, mas porque eu cansei de ser um registro de pacotes humano. Um repositório, código compartilhado, mudanças atômicas em cada app que o consome.
Mas monorepos têm seus próprios modos de falha. Eu tentei três ferramentas diferentes, desperdicei semanas com cache quebrado, lutei contra erros de dependência circular à meia-noite, e aprendi que "é só colocar tudo em um repo" é tão útil quanto um conselho pode ser sem os detalhes.
Este é o setup Turborepo que eu realmente uso. Ele roda quatro apps em produção com doze pacotes compartilhados. Builds levam menos de 90 segundos por causa do cache remoto. Novos desenvolvedores podem clonar e rodar pnpm dev e ter tudo funcionando em menos de dois minutos. Levou cerca de um mês de erros para chegar aqui.
Por Que Monorepo em Primeiro Lugar#
Vamos ser honestos sobre os tradeoffs. Um monorepo não é gratuito. Você está trocando um conjunto de problemas por outro, e precisa saber o que está comprando.
O Que Você Ganha#
Compartilhamento de código sem publicar. Esse é o grande. Você escreve uma biblioteca de componentes React em packages/ui. Seu app web, seu dashboard admin e seu site de documentação importam dela. Quando você muda um componente de botão, cada app o pega imediatamente. Sem bump de versão, sem npm publish, sem "qual versão está em produção mesmo?"
// Em apps/web/src/components/Header.tsx
import { Button, Avatar, Dropdown } from "@acme/ui";
import { formatDate } from "@acme/utils";
import { SITE_CONFIG } from "@acme/config";Esses imports parecem pacotes externos. Eles resolvem através de dependências de workspace. Mas apontam para código fonte no mesmo repositório.
Mudanças atômicas entre pacotes. Imagine que você precisa adicionar uma prop isLoading ao seu componente Button compartilhado. Em um mundo polyrepo, você mudaria a biblioteca de componentes, publicaria uma nova versão, atualizaria a versão em cada app consumidor, depois abriria PRs em cada repo. São três a cinco PRs para uma prop.
Em um monorepo, é um commit:
feat: add isLoading prop to Button component
- packages/ui/src/Button.tsx (add prop, render spinner)
- apps/web/src/pages/checkout.tsx (use isLoading)
- apps/admin/src/pages/users.tsx (use isLoading)
Um PR. Um review. Um merge. Tudo fica sincronizado porque fisicamente não pode divergir.
Ferramentas unificadas. Uma configuração ESLint. Uma configuração Prettier. Uma configuração base TypeScript. Um pipeline de CI. Quando você atualiza o ESLint, atualiza em todo lugar em uma tarde, não ao longo de três sprints em oito repos.
O Que Você Paga#
Complexidade na raiz. Seu package.json na raiz se torna infraestrutura. Seu pipeline de CI precisa entender quais pacotes mudaram. Sua IDE pode ter dificuldade com a quantidade de arquivos. Operações Git ficam mais lentas conforme o repo cresce (embora isso leve anos para a maioria dos times).
Tempo de CI pode inflar. Se você builda tudo em cada commit, vai desperdiçar quantidades enormes de computação. Você precisa de uma ferramenta que entenda o grafo de dependências e só builde o que mudou. Essa é a razão inteira pela qual o Turborepo existe.
Fricção de onboarding. Novos desenvolvedores precisam entender workspaces, hoisting, pacotes internos, o pipeline de build. Não é apenas "clone e rode." Bom, deveria ser eventualmente, mas chegar lá requer esforço deliberado.
A avaliação honesta: se você tem um app sem código compartilhado, um monorepo é overhead sem benefício. Se você tem dois ou mais apps que compartilham qualquer coisa — componentes, utilidades, tipos, configs — o monorepo se paga dentro do primeiro mês.
Turborepo vs Nx vs Lerna#
Eu usei todos os três. Aqui está a versão curta.
Lerna foi a ferramenta monorepo original para JavaScript. Ela gerencia versionamento e publicação. Mas não entende pipelines de build ou cache. Foi abandonada, revivida pelo Nx, e agora parece mais uma camada de compatibilidade do que uma ferramenta standalone. Se você precisa publicar pacotes no npm, Lerna + Nx consegue lidar. Mas para monorepos internos onde você está apenas compartilhando código entre seus próprios apps, é mais cerimônia do que você precisa.
Nx é poderoso. Realmente poderoso. Tem generators, plugins para cada framework, um grafo visual de dependências, execução distribuída de tarefas. Também tem uma curva de aprendizado que parece uma parede de escalada. Eu vi times gastando duas semanas apenas configurando Nx antes de escrever qualquer código de produto. Se você está em uma empresa com 50+ desenvolvedores e centenas de pacotes, Nx provavelmente é a escolha certa. Para meus casos de uso, é uma escavadeira quando eu preciso de uma pá.
Turborepo faz três coisas bem: entende seu grafo de dependências, cacheia saídas de build e executa tarefas em paralelo. Só isso. A configuração inteira é um arquivo turbo.json. Você pode lê-lo inteiro em dois minutos. Ele não gera código, não tem plugins, não tenta substituir sua ferramenta de build. É um task runner que é muito, muito bom em saber o que precisa rodar e o que pode ser pulado.
Eu escolhi Turborepo porque consegui explicar o setup inteiro para um novo membro do time em 15 minutos. Com Nx, a mesma conversa levou uma hora e eles ainda tinham perguntas.
Recurso | Turborepo | Nx | Lerna
------------------+---------------+-----------------+----------------
Complexidade cfg | Baixa (1 arq) | Alta (múltiplos)| Média
Curva aprendizado | ~1 dia | ~1 semana | ~2 dias
Cache de build | Sim (remoto) | Sim (remoto) | Não (nativo)
Orquestração | Sim | Sim | Básica
Geração de código | Não | Sim (extensiva) | Não
Plugins framework | Não | Sim | Não
Melhor para | Peq-médio | Grandes orgs | Publicação
Estrutura do Repositório#
Aqui está a árvore de diretórios real. Não um exemplo "getting started" — isso é como um monorepo de produção se parece após seis meses.
acme-monorepo/
├── apps/
│ ├── web/ # App principal voltado ao cliente
│ │ ├── src/
│ │ ├── public/
│ │ ├── next.config.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── admin/ # Dashboard admin interno
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── docs/ # Site de documentação
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ └── api/ # Servidor API Express/Fastify
│ ├── src/
│ ├── package.json
│ └── tsconfig.json
├── packages/
│ ├── ui/ # Biblioteca de componentes React compartilhada
│ │ ├── src/
│ │ │ ├── components/
│ │ │ │ ├── Button.tsx
│ │ │ │ ├── Input.tsx
│ │ │ │ ├── Modal.tsx
│ │ │ │ └── index.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── utils/ # Funções utilitárias compartilhadas
│ │ ├── src/
│ │ │ ├── date.ts
│ │ │ ├── string.ts
│ │ │ ├── validation.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── types/ # Tipos TypeScript compartilhados
│ │ ├── src/
│ │ │ ├── user.ts
│ │ │ ├── api.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── config-typescript/ # Presets de tsconfig compartilhados
│ │ ├── base.json
│ │ ├── nextjs.json
│ │ ├── node.json
│ │ └── package.json
│ ├── config-eslint/ # Configurações ESLint compartilhadas
│ │ ├── base.js
│ │ ├── next.js
│ │ ├── node.js
│ │ └── package.json
│ └── config-tailwind/ # Presets Tailwind compartilhados
│ ├── tailwind.config.ts
│ └── package.json
├── turbo.json
├── pnpm-workspace.yaml
├── package.json
└── tsconfig.json
A Divisão apps/ vs packages/#
Este é o princípio organizacional fundamental. apps/ contém coisas deployáveis — apps web, APIs, qualquer coisa com um comando dev ou start. packages/ contém bibliotecas — código que existe para ser consumido por apps ou outros pacotes.
A regra é simples: apps consomem pacotes. Pacotes nunca importam de apps. Pacotes podem importar de outros pacotes.
Se um pacote começa a importar de um app, você inverteu o grafo de dependências e o Turborepo vai detectar como um ciclo.
Configuração do Workspace#
O pnpm-workspace.yaml na raiz diz ao pnpm onde encontrar pacotes:
packages:
- "apps/*"
- "packages/*"Esse é o arquivo inteiro. O pnpm escaneia esses diretórios, lê cada package.json e cria um mapa de workspace. Quando apps/web declara "@acme/ui": "workspace:*" como dependência, o pnpm vincula ao packages/ui local em vez de procurar no registro npm.
package.json na Raiz#
{
"name": "acme-monorepo",
"private": true,
"scripts": {
"build": "turbo build",
"dev": "turbo dev",
"lint": "turbo lint",
"test": "turbo test",
"clean": "turbo clean",
"format": "prettier --write \"**/*.{ts,tsx,md,json}\""
},
"devDependencies": {
"prettier": "^3.4.2",
"turbo": "^2.4.4",
"typescript": "^5.7.3"
},
"packageManager": "pnpm@9.15.4",
"engines": {
"node": ">=20"
}
}Note: o package.json na raiz não tem dependências de runtime. É puramente orquestração. turbo é o task runner, prettier cuida da formatação (porque é a única ferramenta que não precisa de configuração por pacote), e typescript é hoisted para que todo pacote use a mesma versão.
Convenções de Nomenclatura#
Todo pacote recebe um nome com escopo: @acme/ui, @acme/utils, @acme/config-typescript. O escopo previne colisões com pacotes npm e torna imediatamente óbvio em qualquer import se você está usando código interno ou externo.
// Pacote externo - do npm
import { clsx } from "clsx";
// Pacote interno - do nosso monorepo
import { Button } from "@acme/ui";Eu prefixo pacotes de configuração com config- para agrupá-los visualmente: @acme/config-typescript, @acme/config-eslint, @acme/config-tailwind. Alguns times usam @acme/tsconfig, @acme/eslint-config. Ambos funcionam. Apenas seja consistente.
Configuração do Pipeline turbo.json#
É aqui que o Turborepo ganha seu sustento. O arquivo turbo.json define seu pipeline de tarefas — quais tarefas existem, do que dependem e o que produzem.
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": [
"**/.env.*local"
],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"],
"inputs": ["src/**", "package.json", "tsconfig.json"]
},
"lint": {
"dependsOn": ["^build"],
"cache": true
},
"test": {
"dependsOn": ["^build"],
"cache": true,
"inputs": ["src/**", "test/**", "**/*.test.ts", "**/*.test.tsx"]
},
"dev": {
"cache": false,
"persistent": true
},
"clean": {
"cache": false
},
"type-check": {
"dependsOn": ["^build"],
"cache": true
}
}
}Deixe-me detalhar cada campo porque é aqui que a maioria das pessoas fica confusa.
dependsOn e a Notação ^#
"dependsOn": ["^build"] significa: "Antes de executar esta tarefa no pacote X, primeiro execute build em cada pacote do qual X depende."
O símbolo ^ significa "dependências upstream." Sem ele, "dependsOn": ["build"] significaria "executar build no mesmo pacote primeiro" — útil se sua tarefa test precisa de um build acontecer primeiro dentro do mesmo pacote.
Aqui está um exemplo concreto. Seu grafo de dependências é assim:
apps/web → @acme/ui → @acme/utils
→ @acme/types
Quando você roda turbo build, o Turborepo resolve o grafo:
- Build
@acme/types(sem dependências) - Build
@acme/utils(sem dependências) - Build
@acme/ui(depende de types e utils — espera por eles) - Build
apps/web(depende de ui — espera por ele)
Passos 1 e 2 rodam em paralelo. Passo 3 espera por ambos. Passo 4 espera por 3. O Turborepo descobre isso a partir das declarações de dependência do seu package.json. Você não precisa especificar a ordem manualmente.
outputs#
Isso é crítico para o cache. Quando o Turborepo cacheia um build, ele armazena os arquivos listados em outputs. Quando obtém um cache hit, ele restaura esses arquivos. Se você esquecer de listar um diretório de saída, o cache vai "ter sucesso" mas seus arquivos não vão aparecer. Esse foi meu maior erro na primeira semana — eu esqueci .next/** e não conseguia entender por que meu app Next.js estava sempre rebuildando.
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]A exclusão !.next/cache/** é importante. O Next.js tem seu próprio cache dentro de .next/cache/. Você não quer armazenar um cache de um cache — isso incha seu armazenamento de cache remoto e não ajuda.
inputs#
Por padrão, o Turborepo faz hash de cada arquivo em um pacote para determinar se mudou. O campo inputs reduz isso. Se apenas arquivos em src/ afetam a saída do build, então mudar README.md não deveria invalidar o cache.
"inputs": ["src/**", "package.json", "tsconfig.json"]Tenha cuidado com isso. Se você adiciona um arquivo de configuração que afeta seu build (digamos, postcss.config.js) e não o inclui em inputs, você vai obter saída cacheada obsoleta. Na dúvida, deixe inputs sem definir e deixe o Turborepo fazer hash de tudo.
globalDependencies#
Arquivos listados aqui invalidam o cache para todo pacote quando mudam. Arquivos de ambiente são o exemplo clássico — se seu .env.local muda, tudo que pode ler de process.env precisa ser rebuildado.
"globalDependencies": ["**/.env.*local"]Eu também adiciono tsconfig.json no nível raiz aqui porque minha configuração base de TypeScript afeta todos os pacotes:
"globalDependencies": [
"**/.env.*local",
"tsconfig.json"
]persistent e dev#
A tarefa dev tem "cache": false e "persistent": true. Cachear um servidor dev não faz sentido — é um processo de longa duração. A flag persistent diz ao Turborepo que essa tarefa não encerra, então ele não deve esperar por ela antes de executar outras tarefas.
Quando você roda turbo dev, o Turborepo inicia todos os servidores dev em paralelo — seu app Next.js, seu servidor API, seu site de docs — todos em um terminal com saída intercalada. Cada linha é prefixada com o nome do pacote para que você possa diferenciá-los.
Pacotes Compartilhados em Detalhe#
packages/ui — A Biblioteca de Componentes#
Este é o pacote que todo time constrói primeiro. Componentes React compartilhados usados em todos os apps frontend.
{
"name": "@acme/ui",
"version": "0.0.0",
"private": true,
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./styles.css": "./src/styles.css"
},
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --external react",
"dev": "tsup src/index.ts --format esm,cjs --dts --external react --watch",
"clean": "rm -rf dist",
"lint": "eslint src/",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@acme/utils": "workspace:*",
"@acme/types": "workspace:*"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"@acme/config-eslint": "workspace:*",
"tsup": "^8.3.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.7.3"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
}Algumas coisas a notar:
"version": "0.0.0" — Pacotes internos não precisam de versões reais. O protocolo workspace:* diz ao pnpm para sempre usar a cópia local. Números de versão são irrelevantes.
"private": true — Este pacote nunca será publicado no npm. Se você algum dia quiser publicá-lo, remova esta flag e configure uma estratégia de versionamento adequada.
O campo exports — Esta é a forma moderna de definir entry points de pacote. Substitui os campos main, module e types. O export "." é o caminho de import padrão. Você pode adicionar sub-path exports para imports granulares:
{
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./Button": {
"types": "./src/components/Button.tsx",
"default": "./src/components/Button.tsx"
},
"./styles.css": "./src/styles.css"
}
}Decisão importante: buildar ou não? Alguns times pulam a etapa de build inteiramente para pacotes internos. Em vez de compilar com tsup, eles apontam os exports diretamente para o código TypeScript fonte e deixam o bundler do app consumidor (Next.js, Vite) cuidar da compilação. Isso é chamado de "pacotes internos" na terminologia do Turborepo e é mais simples:
{
"name": "@acme/ui",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts"
}
}Sem etapa de build. Sem pasta dist. O next.config.ts do app consumidor precisa de transpilePackages:
// apps/web/next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ["@acme/ui", "@acme/utils"],
};
export default nextConfig;Eu uso a abordagem sem build para a maioria dos pacotes internos. É mais rápido durante o desenvolvimento e uma coisa a menos para quebrar. A exceção são pacotes que precisam suportar consumidores não-TypeScript ou têm requisitos de build complexos.
packages/utils — Utilitários Compartilhados#
{
"name": "@acme/utils",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts",
"./date": "./src/date.ts",
"./string": "./src/string.ts",
"./validation": "./src/validation.ts"
},
"dependencies": {
"@acme/types": "workspace:*"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"typescript": "^5.7.3"
}
}Os sub-path exports permitem que apps consumidores importem apenas o que precisam:
// Importa apenas utilitários de data, não o pacote inteiro
import { formatRelativeDate, parseISO } from "@acme/utils/date";
// Importa tudo (barrel export)
import { formatRelativeDate, slugify, validateEmail } from "@acme/utils";O barrel export em src/index.ts re-exporta tudo:
// packages/utils/src/index.ts
export * from "./date";
export * from "./string";
export * from "./validation";Um aviso sobre barrel exports: são convenientes mas podem matar tree-shaking em alguns bundlers. Se apps/web importa uma função de @acme/utils, um bundler ingênuo pode incluir tudo. O Next.js lida bem com isso com seu tree-shaking embutido, mas se você notar problemas de tamanho de bundle, o padrão de sub-path exports é mais seguro.
packages/types — Tipos TypeScript Compartilhados#
{
"name": "@acme/types",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts",
"./api": "./src/api.ts",
"./user": "./src/user.ts"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"typescript": "^5.7.3"
}
}Este pacote tem zero dependências de runtime. Contém apenas tipos e interfaces TypeScript. Nunca precisa ser buildado porque tipos são apagados em tempo de compilação.
// packages/types/src/user.ts
export interface User {
id: string;
email: string;
name: string;
role: "admin" | "user" | "viewer";
createdAt: Date;
updatedAt: Date;
}
export interface CreateUserInput {
email: string;
name: string;
role?: User["role"];
}
export interface UserFilters {
role?: User["role"];
search?: string;
page?: number;
limit?: number;
}// packages/types/src/api.ts
export interface ApiResponse<T> {
data: T;
meta?: {
page: number;
limit: number;
total: number;
};
}
export interface ApiError {
code: string;
message: string;
details?: Record<string, string[]>;
}
export type ApiResult<T> =
| { success: true; data: T }
| { success: false; error: ApiError };packages/config-typescript — TSConfigs Compartilhados#
Este é enganosamente importante. Configuração de TypeScript em um monorepo é onde as coisas ficam complicadas rápido.
{
"name": "@acme/config-typescript",
"version": "0.0.0",
"private": true,
"files": [
"base.json",
"nextjs.json",
"node.json",
"react-library.json"
]
}A configuração base que todos os pacotes estendem:
// packages/config-typescript/base.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true
},
"exclude": ["node_modules"]
}A configuração específica para Next.js:
// packages/config-typescript/nextjs.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"noEmit": true,
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "preserve",
"incremental": true,
"plugins": [
{ "name": "next" }
]
}
}A configuração Node.js para pacotes backend:
// packages/config-typescript/node.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src"
}
}Agora cada app ou pacote estende o preset apropriado:
// apps/web/tsconfig.json
{
"extends": "@acme/config-typescript/nextjs",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}// apps/api/tsconfig.json
{
"extends": "@acme/config-typescript/node",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}// packages/ui/tsconfig.json
{
"extends": "@acme/config-typescript/base",
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}O Path Alias @/*#
Todo app recebe o mesmo path alias @/* apontando para seu próprio diretório src/. Isso significa que @/components/Header em apps/web resolve para apps/web/src/components/Header, e @/components/Header em apps/admin resolve para apps/admin/src/components/Header.
Esses são caminhos locais do app. Não cruzam fronteiras de pacotes. Para imports entre pacotes, você sempre usa o nome do pacote: @acme/ui, não @/../../packages/ui. Se você vir um import relativo que sobe além da raiz do pacote (../../packages/something), isso é um code smell. Use a dependência de workspace em vez disso.
composite e Project References#
Project references do TypeScript (composite: true) permitem que tsc builde pacotes incrementalmente e entenda dependências entre projetos. Isso é opcional com Turborepo — o Turborepo cuida da orquestração de build por si só — mas pode acelerar a verificação de tipos na sua IDE.
// packages/ui/tsconfig.json (com composite)
{
"extends": "@acme/config-typescript/base",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx"
},
"include": ["src"],
"references": [
{ "path": "../types" },
{ "path": "../utils" }
]
}Serei honesto: project references adicionam complexidade e eu as removi da maioria dos meus setups. O --filter e dependsOn do Turborepo já cuidam da ordem de build. Eu só adiciono composite: true quando a performance da IDE se torna um problema (geralmente com 15+ pacotes).
packages/config-eslint — Linting Compartilhado#
// packages/config-eslint/base.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
{
rules: {
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" }
],
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/consistent-type-imports": [
"error",
{ prefer: "type-imports" }
],
},
}
);// packages/config-eslint/next.js
import baseConfig from "./base.js";
import nextPlugin from "@next/eslint-plugin-next";
import reactPlugin from "eslint-plugin-react";
import hooksPlugin from "eslint-plugin-react-hooks";
export default [
...baseConfig,
{
plugins: {
"@next/next": nextPlugin,
"react": reactPlugin,
"react-hooks": hooksPlugin,
},
rules: {
...nextPlugin.configs.recommended.rules,
...nextPlugin.configs["core-web-vitals"].rules,
"react-hooks/exhaustive-deps": "warn",
},
},
];Cada app referencia sua configuração:
// apps/web/eslint.config.mjs
import nextConfig from "@acme/config-eslint/next";
export default [
...nextConfig,
{
ignores: [".next/"],
},
];Cache Remoto#
O cache local é legal — ele evita que você reconstrua pacotes inalterados durante o desenvolvimento. Mas o cache remoto é transformador. Ele significa que seu servidor de CI se beneficia dos seus builds locais e vice-versa.
Como Funciona#
Quando o Turborepo executa uma tarefa, ele calcula um hash baseado em:
- Os arquivos fonte (hash de conteúdo)
- A configuração de
inputsda tarefa - Variáveis de ambiente
- Os hashes das dependências upstream
Se o hash corresponde a um resultado previamente cacheado, o Turborepo restaura os outputs do cache em vez de executar a tarefa. Com cache remoto, essas saídas cacheadas são armazenadas em um local compartilhado que qualquer máquina — seu notebook, o notebook do seu colega, o CI — pode acessar.
Cache Remoto da Vercel#
O setup mais simples. A Vercel mantém a infraestrutura de cache gratuitamente (com limites):
# Login na Vercel (configuração única)
npx turbo login
# Vincule seu repo a um projeto Vercel
npx turbo linkÉ isso. O Turborepo agora vai enviar e buscar artefatos de cache dos servidores da Vercel. No CI, você configura variáveis de ambiente:
# .github/workflows/ci.yml
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}Gere o token no dashboard da Vercel nas configurações do seu time. O TURBO_TEAM é o slug do seu time.
Cache Remoto Auto-Hospedado#
Se você não pode usar a Vercel (ambiente air-gapped, requisitos de residência de dados), você pode hospedar você mesmo. A API de cache remoto do Turborepo é bem documentada e existem várias implementações open-source:
- ducktors/turborepo-remote-cache — Um servidor Node.js que armazena artefatos no S3, GCS ou sistema de arquivos local
- fox1t/turborepo-remote-cache — Similar, com suporte a Azure Blob
# docker-compose.yml para cache auto-hospedado
services:
turbo-cache:
image: ducktors/turborepo-remote-cache:latest
ports:
- "3000:3000"
environment:
TURBO_TOKEN: "your-secret-token"
STORAGE_PROVIDER: "s3"
S3_ACCESS_KEY: "${AWS_ACCESS_KEY_ID}"
S3_SECRET_KEY: "${AWS_SECRET_ACCESS_KEY}"
S3_REGION: "eu-central-1"
S3_ENDPOINT: "https://s3.eu-central-1.amazonaws.com"
S3_BUCKET: "turbo-cache"Então configure seu monorepo para usá-lo:
// .turbo/config.json
{
"teamId": "team_acme",
"apiUrl": "https://turbo-cache.internal.acme.com"
}Taxas de Acerto do Cache#
Na minha experiência, um Turborepo bem configurado atinge 80-95% de taxa de acerto do cache no CI. Isso significa que apenas 5-20% das tarefas realmente executam em qualquer pipeline de CI. O restante é restaurado do cache em segundos.
As chaves para altas taxas de acerto do cache:
- Restrinja seus
inputs. Se apenassrc/afeta o build, não deixe uma mudança no README invalidar o cache. - Não coloque dados voláteis em
globalDependencies. Um arquivo.envque muda a cada deploy mata seu cache. - Fixe suas dependências. Mudanças no lockfile invalidam tudo downstream.
- Mantenha pacotes pequenos e focados. Um gigante
packages/utilscom 200 arquivos terá seu cache invalidado frequentemente porque qualquer mudança em qualquer utilitário invalida o pacote inteiro.
CI/CD com Turborepo#
Aqui está o workflow do GitHub Actions que eu uso. Foi refinado ao longo de dezenas de iterações.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
jobs:
ci:
name: Build, Lint, Test
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2 # Need parent commit for change detection
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm turbo build
- name: Lint
run: pnpm turbo lint
- name: Type Check
run: pnpm turbo type-check
- name: Test
run: pnpm turbo testA Flag --filter#
A flag --filter é como você executa tarefas para pacotes específicos ou baseado no que mudou. Isso é essencial para CI eficiente:
# Builda apenas o app web e suas dependências
pnpm turbo build --filter=apps/web...
# Builda apenas pacotes que mudaram desde a main
pnpm turbo build --filter=...[main]
# Builda apenas @acme/ui e tudo que depende dele
pnpm turbo build --filter=...@acme/ui
# Builda apenas pacotes no diretório packages/
pnpm turbo build --filter="./packages/*"A sintaxe de três pontos é importante:
--filter=@acme/ui...— O pacote e todas as suas dependências (coisas que ele precisa)--filter=...@acme/ui— O pacote e todos os seus dependentes (coisas que precisam dele)--filter=@acme/ui— Apenas o pacote em si
Detecção de Pacotes Afetados#
Para pull requests, você provavelmente não quer buildar tudo. Você quer buildar apenas o que é afetado pelas mudanças:
# Builda/testa apenas o que mudou comparado à main
- name: Build affected
run: pnpm turbo build --filter="...[origin/main]"
- name: Test affected
run: pnpm turbo test --filter="...[origin/main]"A sintaxe [origin/main] diz ao Turborepo para comparar o commit atual com origin/main e só executar tarefas para pacotes que têm mudanças. Combinado com cache remoto, a maioria dos pipelines de PR terminam em menos de 2 minutos, mesmo para grandes monorepos.
Estratégia de Matrix para Deploys#
Se cada app faz deploy independentemente, use uma estratégia de matrix:
deploy:
needs: ci
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
strategy:
matrix:
app: [web, admin, docs, api]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm turbo build --filter=apps/${{ matrix.app }}...
- name: Deploy ${{ matrix.app }}
run: ./scripts/deploy.sh ${{ matrix.app }}
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}Cada job de matrix builda um app e suas dependências. Graças ao cache remoto, os pacotes compartilhados são buildados uma vez e cacheados — cada job de matrix subsequente os restaura do cache.
Armadilhas Comuns#
Estou dedicando uma seção inteira a isso porque caí em cada uma delas no meu primeiro mês. Aprender pela documentação é eficiente. Aprender com meus erros é grátis.
1. Dependências Circulares#
O grafo de dependências deve ser um DAG (grafo acíclico direcionado). Se @acme/ui importa de @acme/utils e @acme/utils importa de @acme/ui, você tem um ciclo. O Turborepo vai reclamar e se recusar a buildar.
A correção é quase sempre extrair o código compartilhado para um terceiro pacote:
ANTES (quebrado):
@acme/ui → @acme/utils → @acme/ui ← ciclo!
DEPOIS (corrigido):
@acme/ui → @acme/types
@acme/utils → @acme/types
Mova os tipos ou constantes compartilhadas para @acme/types e faça ambos os pacotes dependerem dele.
Outra causa comum: você coloca um hook React em @acme/utils que importa um componente de @acme/ui. Hooks que dependem de componentes UI pertencem a @acme/ui, não a @acme/utils. Utilitários devem ser agnósticos de framework sempre que possível.
2. outputs Faltando Invalidando o Cache#
Este é o bug mais frustrante. Tudo parece funcionar localmente. Builds do CI passam. Mas cada build leva o tempo completo porque nada está cacheado.
Cenário: seu pacote builda para build/ em vez de dist/, mas seu turbo.json só lista dist/** em outputs:
// turbo.json
"build": {
"outputs": ["dist/**"] // mas seu pacote builda para build/
}O Turborepo cacheia a tarefa (porque executou com sucesso) mas não armazena nenhum arquivo de saída. Da próxima vez, ele obtém um "acerto" de cache e não restaura nada. O app consumidor não encontra a saída do build e falha, ou — pior — silenciosamente usa arquivos obsoletos.
Correção: audite o diretório de saída do build de cada pacote e certifique-se de que o turbo.json cubra todos:
"build": {
"outputs": ["dist/**", "build/**", ".next/**", "!.next/cache/**"]
}Ou use overrides por pacote no turbo.json:
{
"tasks": {
"build": {
"outputs": ["dist/**"]
}
},
// Overrides por pacote
"packages": {
"apps/web": {
"build": {
"outputs": [".next/**", "!.next/cache/**"]
}
},
"apps/api": {
"build": {
"outputs": ["build/**"]
}
}
}
}3. Coordenação de Servidores Dev#
Executar turbo dev inicia todos os servidores dev simultaneamente. Isso é tranquilo até seus apps tentarem usar a mesma porta. Atribuição explícita de portas é obrigatória:
// apps/web/package.json
"scripts": {
"dev": "next dev --port 3000"
}
// apps/admin/package.json
"scripts": {
"dev": "next dev --port 3001"
}
// apps/docs/package.json
"scripts": {
"dev": "next dev --port 3002"
}
// apps/api/package.json
"scripts": {
"dev": "tsx watch src/index.ts" // Usa porta 4000 da config
}Outro problema: hot module replacement para pacotes compartilhados. Quando você edita um arquivo em packages/ui, a mudança precisa propagar para o servidor dev Next.js em execução. Se você está usando a abordagem de "pacotes internos" (sem etapa de build, imports diretos de TypeScript), isso funciona automaticamente — o Next.js observa os arquivos fonte através do node_modules com symlink.
Se seu pacote tem uma etapa de build, você precisa que o script dev do pacote rode em modo watch:
// packages/ui/package.json
"scripts": {
"dev": "tsup src/index.ts --format esm,cjs --dts --external react --watch"
}O Turborepo executa todas as tarefas dev em paralelo, então o pacote reconstrói na mudança e o app consumidor pega a nova saída.
4. Divergência de Versão Entre Pacotes#
Isso é sutil e perigoso. Se apps/web usa React 19 e apps/admin acidentalmente usa React 18, você pode não perceber até que um componente de @acme/ui se comporte de maneira diferente em cada app.
Solução: gerencie dependências compartilhadas no nível raiz. O campo overrides do pnpm no package.json raiz força uma versão única em todo lugar:
// package.json raiz
{
"pnpm": {
"overrides": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.7.3"
}
}
}Execute pnpm ls react --recursive regularmente para verificar se tudo usa a mesma versão.
5. Variáveis de Ambiente em Pacotes Compartilhados#
Pacotes compartilhados não devem ler process.env diretamente. Se @acme/utils lê process.env.API_URL, isso cria um acoplamento invisível com o ambiente do app consumidor. Em vez disso, passe a configuração explicitamente:
// RUIM: packages/utils/src/api.ts
const API_URL = process.env.API_URL; // Qual env? Qual app?
export function fetchData(path: string) {
return fetch(`${API_URL}${path}`);
}
// BOM: packages/utils/src/api.ts
export function createApiClient(baseUrl: string) {
return {
fetch: (path: string) => fetch(`${baseUrl}${path}`),
};
}O app consumidor fornece a configuração:
// apps/web/src/lib/api.ts
import { createApiClient } from "@acme/utils";
export const api = createApiClient(process.env.NEXT_PUBLIC_API_URL!);Isso mantém os pacotes puros e testáveis.
6. Dependências Fantasma#
O pnpm é rigoroso com dependências por padrão — ele não faz hoisting de pacotes da forma que o npm faz. Isso é uma feature, não um bug. Significa que se @acme/ui importa clsx mas não o lista no seu package.json, o pnpm vai lançar um erro. O npm resolveria silenciosamente de um node_modules pai.
Sempre declare cada import no package.json do pacote consumidor. Sem mais depender de hoisting.
7. Degradação de Performance da IDE#
Com 15+ pacotes, seu language server TypeScript pode começar a sofrer. Algumas dicas:
- Adicione
"exclude": ["node_modules", "**/dist/**"]ao seutsconfig.jsonraiz - Use "Files: Exclude" do VS Code para ocultar pastas
dist/,.next/e.turbo/ - Considere
"disableSourceOfProjectReferenceRedirect": trueno tsconfig se "Go to Definition" te manda para arquivos.d.tsno fundo donode_modules
A Estrutura Inicial: Tudo Junto#
Deixe-me juntar tudo. Aqui está cada arquivo que você precisa para iniciar um monorepo Turborepo com dois apps Next.js e três pacotes compartilhados.
Raiz#
// package.json
{
"name": "acme-monorepo",
"private": true,
"scripts": {
"build": "turbo build",
"dev": "turbo dev",
"lint": "turbo lint",
"test": "turbo test",
"type-check": "turbo type-check",
"clean": "turbo clean",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\""
},
"devDependencies": {
"prettier": "^3.4.2",
"turbo": "^2.4.4",
"typescript": "^5.7.3"
},
"packageManager": "pnpm@9.15.4",
"engines": {
"node": ">=20"
},
"pnpm": {
"overrides": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
}# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": [
"**/.env.*local",
"tsconfig.json"
],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"],
"inputs": ["src/**", "package.json", "tsconfig.json"]
},
"lint": {
"dependsOn": ["^build"],
"cache": true
},
"test": {
"dependsOn": ["^build"],
"cache": true,
"inputs": [
"src/**",
"test/**",
"**/*.test.ts",
"**/*.test.tsx",
"vitest.config.ts"
]
},
"type-check": {
"dependsOn": ["^build"],
"cache": true
},
"dev": {
"cache": false,
"persistent": true
},
"clean": {
"cache": false
}
}
}// tsconfig.json (raiz — apenas para IDE, não usado por builds)
{
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"exclude": ["node_modules", "**/dist/**", "**/.next/**"]
}apps/web#
// apps/web/package.json
{
"name": "web",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "next dev --port 3000",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf .next"
},
"dependencies": {
"@acme/ui": "workspace:*",
"@acme/utils": "workspace:*",
"@acme/types": "workspace:*",
"next": "^15.2.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"@acme/config-eslint": "workspace:*",
"@types/node": "^22.13.5",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"typescript": "^5.7.3",
"vitest": "^3.0.7"
}
}// apps/web/next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ["@acme/ui", "@acme/utils"],
reactStrictMode: true,
};
export default nextConfig;// apps/web/tsconfig.json
{
"extends": "@acme/config-typescript/nextjs",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}apps/admin#
// apps/admin/package.json
{
"name": "admin",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "next dev --port 3001",
"build": "next build",
"start": "next start --port 3001",
"lint": "next lint",
"type-check": "tsc --noEmit",
"clean": "rm -rf .next"
},
"dependencies": {
"@acme/ui": "workspace:*",
"@acme/utils": "workspace:*",
"@acme/types": "workspace:*",
"next": "^15.2.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"@acme/config-eslint": "workspace:*",
"@types/node": "^22.13.5",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"typescript": "^5.7.3"
}
}packages/ui#
// packages/ui/package.json
{
"name": "@acme/ui",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts"
},
"scripts": {
"lint": "eslint src/",
"type-check": "tsc --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {
"@acme/utils": "workspace:*",
"@acme/types": "workspace:*"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"@acme/config-eslint": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.7.3"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
}// packages/ui/src/index.ts
export { Button } from "./components/Button";
export { Input } from "./components/Input";
export { Modal } from "./components/Modal";
export { Card } from "./components/Card";
export { Badge } from "./components/Badge";
// Re-export types
export type { ButtonProps } from "./components/Button";
export type { InputProps } from "./components/Input";
export type { ModalProps } from "./components/Modal";// packages/ui/src/components/Button.tsx
import { forwardRef, type ButtonHTMLAttributes } from "react";
import { cn } from "@acme/utils";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "outline" | "ghost" | "destructive";
size?: "sm" | "md" | "lg";
isLoading?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "primary", size = "md", isLoading, children, disabled, ...props }, ref) => {
return (
<button
ref={ref}
className={cn(
"inline-flex items-center justify-center rounded-lg font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
"disabled:pointer-events-none disabled:opacity-50",
{
"bg-blue-600 text-white hover:bg-blue-700": variant === "primary",
"bg-gray-100 text-gray-900 hover:bg-gray-200": variant === "secondary",
"border border-gray-300 bg-transparent hover:bg-gray-100": variant === "outline",
"bg-transparent hover:bg-gray-100": variant === "ghost",
"bg-red-600 text-white hover:bg-red-700": variant === "destructive",
},
{
"h-8 px-3 text-sm": size === "sm",
"h-10 px-4 text-sm": size === "md",
"h-12 px-6 text-base": size === "lg",
},
className
)}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? (
<>
<svg
className="mr-2 h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{children}
</>
) : (
children
)}
</button>
);
}
);
Button.displayName = "Button";packages/utils#
// packages/utils/package.json
{
"name": "@acme/utils",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts",
"./date": "./src/date.ts",
"./string": "./src/string.ts",
"./cn": "./src/cn.ts"
},
"scripts": {
"lint": "eslint src/",
"type-check": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist"
},
"dependencies": {
"clsx": "^2.1.1",
"tailwind-merge": "^3.0.1"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"typescript": "^5.7.3",
"vitest": "^3.0.7"
}
}// packages/utils/src/cn.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}// packages/utils/src/date.ts
export function formatRelativeDate(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSeconds < 60) return "just now";
if (diffMinutes < 60) return `${diffMinutes}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}
export function formatDate(date: Date, locale = "en-US"): string {
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
}// packages/utils/src/string.ts
export function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_]+/g, "-")
.replace(/-+/g, "-");
}
export function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength).trimEnd() + "...";
}
export function capitalize(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1);
}// packages/utils/src/index.ts
export { cn } from "./cn";
export { formatRelativeDate, formatDate } from "./date";
export { slugify, truncate, capitalize } from "./string";packages/types#
// packages/types/package.json
{
"name": "@acme/types",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts",
"./user": "./src/user.ts",
"./api": "./src/api.ts"
},
"devDependencies": {
"@acme/config-typescript": "workspace:*",
"typescript": "^5.7.3"
}
}.gitignore#
# Dependencies
node_modules/
# Build outputs
dist/
build/
.next/
out/
# Turborepo
.turbo/
# Environment
.env
.env.local
.env.*.local
# IDE
.vscode/settings.json
.idea/
# OS
.DS_Store
Thumbs.db
Começando do Zero#
Se você está começando do zero, aqui está a sequência exata de comandos:
# Crie o repo
mkdir acme-monorepo && cd acme-monorepo
git init
# Inicialize o pacote raiz
pnpm init
# Crie o arquivo de workspace
echo 'packages:\n - "apps/*"\n - "packages/*"' > pnpm-workspace.yaml
# Instale o Turborepo
pnpm add -D turbo -w
# Crie os diretórios
mkdir -p apps/web apps/admin
mkdir -p packages/ui/src packages/utils/src packages/types/src
mkdir -p packages/config-typescript packages/config-eslint
# Inicialize os apps (a partir dos seus diretórios)
cd apps/web && pnpm create next-app . --typescript --eslint --tailwind --app
cd ../admin && pnpm create next-app . --typescript --eslint --tailwind --app
cd ../..
# Adicione dependências de workspace
cd packages/ui && pnpm add @acme/utils@workspace:* @acme/types@workspace:*
cd ../..
cd apps/web && pnpm add @acme/ui@workspace:* @acme/utils@workspace:* @acme/types@workspace:*
cd ../..
# Instale tudo
pnpm install
# Verifique se funciona
pnpm turbo build
pnpm turbo devO primeiro pnpm turbo build será lento — tudo é construído do zero. O segundo deve ser quase instantâneo se nada mudou. Esse é o cache funcionando.
Escalando Além do Básico#
Uma vez que você passou da configuração inicial, alguns padrões emergem conforme o monorepo cresce.
Geradores de Pacotes#
Após seu décimo pacote, criar um novo copiando e editando fica tedioso. Crie um script:
// scripts/create-package.ts
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const name = process.argv[2];
if (!name) {
console.error("Usage: tsx scripts/create-package.ts <package-name>");
process.exit(1);
}
const dir = join(process.cwd(), "packages", name);
mkdirSync(join(dir, "src"), { recursive: true });
writeFileSync(
join(dir, "package.json"),
JSON.stringify(
{
name: `@acme/${name}`,
version: "0.0.0",
private: true,
exports: { ".": "./src/index.ts" },
scripts: {
lint: "eslint src/",
"type-check": "tsc --noEmit",
},
devDependencies: {
"@acme/config-typescript": "workspace:*",
typescript: "^5.7.3",
},
},
null,
2
)
);
writeFileSync(
join(dir, "tsconfig.json"),
JSON.stringify(
{
extends: "@acme/config-typescript/base",
compilerOptions: {
outDir: "./dist",
rootDir: "./src",
},
include: ["src"],
exclude: ["node_modules", "dist"],
},
null,
2
)
);
writeFileSync(join(dir, "src", "index.ts"), "// Entry point\n");
console.log(`Created packages/${name}`);
console.log("Run: pnpm install");npx tsx scripts/create-package.ts email
# Cria packages/email com todo o boilerplateVisualização do Grafo de Dependências do Workspace#
O Turborepo tem um comando de grafo embutido:
pnpm turbo build --graph=graph.svgIsso gera um SVG do seu grafo de dependências. Eu rodo isso antes de grandes refatorações para entender o raio de impacto de uma mudança. Se tocar @acme/types reconstrói 12 pacotes, talvez seja hora de dividi-lo em @acme/types-user e @acme/types-billing.
Pruning para Docker#
Quando fazendo deploy de um único app do monorepo, você não quer copiar o repo inteiro para sua imagem Docker. O Turborepo tem um comando prune:
pnpm turbo prune --scope=apps/web --dockerIsso gera um diretório out/ contendo apenas os arquivos necessários para apps/web e suas dependências. Seu Dockerfile usa essa saída podada:
FROM node:20-alpine AS base
FROM base AS builder
WORKDIR /app
COPY out/json/ .
COPY out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY out/pnpm-workspace.yaml ./pnpm-workspace.yaml
RUN corepack enable pnpm && pnpm install --frozen-lockfile
COPY out/full/ .
RUN pnpm turbo build --filter=web...
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/apps/web/.next/standalone ./
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder /app/apps/web/public ./apps/web/public
EXPOSE 3000
CMD ["node", "apps/web/server.js"]A imagem Docker contém apenas o app web e suas dependências. Sem app admin, sem servidor API, sem pacotes não utilizados. Os tamanhos das imagens permanecem razoáveis mesmo conforme o monorepo cresce.
Um Mês Depois: O Que Eu Faria Diferente#
Se eu estivesse começando de novo, sabendo o que sei agora:
-
Comece com pacotes internos (sem etapa de build). Eu desperdicei duas semanas configurando builds tsup para pacotes que apenas meus apps Next.js consumiam.
transpilePackagesna configuração do Next.js é mais simples e mais rápido. Só adicione uma etapa de build quando você precisar de uma. -
Mantenha a contagem de
packages/baixa inicialmente. Eu dividi muito agressivamente no início.packages/utils,packages/typesepackages/uisão suficientes para começar. Você sempre pode dividir depois quando um pacote ficar difícil de manejar. Dividir prematuramente significa mais arquivospackage.jsonpara manter e mais arestas no seu grafo de dependências. -
Configure o cache remoto no primeiro dia.
npx turbo login && npx turbo linkleva 30 segundos. O tempo economizado em builds na primeira semana sozinha já justifica. -
Documente os comandos do workspace. Novos desenvolvedores não sabem que
pnpm --filter @acme/ui add lodashinstala em um pacote específico, ou quepnpm turbo build --filter=apps/web...builda apenas o necessário. Uma simples seção "Cheatsheet do Monorepo" no seu guia de contribuição economiza tempo de todos. -
Imponha a direção de dependência desde o primeiro dia. Se você permitir mesmo um import de um app para um pacote, a fronteira se erode rápido. Adicione uma regra de lint ou uma verificação de CI. A direção é
apps → packages → packages. Nunca o inverso.
O monorepo não é o objetivo. O objetivo é entregar features sem lutar contra sua própria base de código. O Turborepo é a ferramenta mais leve que encontrei para fazer isso funcionar. Ele cuida do grafo de tarefas, cuida do cache e fica fora do caminho para todo o resto.
Comece simples. Adicione complexidade quando o repo exigir. E fixe suas portas.