Turborepo: como organizar um monorepo Node.js que realmente funciona

1. Por que escolher Turborepo para seu monorepo Node.js?

Manter múltiplos projetos Node.js em um único repositório sem ferramentas especializadas rapidamente se torna um pesadelo. Dependências duplicadas, builds lentos e scripts espalhados por dezenas de package.json são problemas comuns que afetam a produtividade da equipe. O Turborepo surge como uma solução elegante para esses desafios.

Comparado a alternativas como Nx, Lerna ou Yarn Workspaces puro, o Turborepo se destaca por três características principais:

  • Cache inteligente: cada tarefa executada gera um hash baseado nos inputs (código-fonte, dependências, configurações). Se nada mudou, o resultado é restaurado do cache em milissegundos.
  • Paralelismo automático: tarefas independentes são executadas simultaneamente, aproveitando ao máximo os recursos da máquina.
  • Simplicidade de configuração: com poucas linhas no turbo.json, você define pipelines completos sem complexidade desnecessária.

Enquanto o Nx oferece mais funcionalidades (geração de código, plugins avançados), o Turborepo foca no essencial: build rápido e confiável para monorepos Node.js. Para a maioria dos projetos, essa simplicidade é um diferencial competitivo.

2. Configuração inicial do Turborepo

A estrutura de pastas recomendada segue o padrão:

meu-monorepo/
├── apps/
│   ├── web/          # Aplicação Next.js
│   ├── api/          # Backend Express
│   └── mobile/       # App React Native
├── packages/
│   ├── ui/           # Componentes compartilhados
│   ├── utils/        # Funções utilitárias
│   └── config/       # Configurações ESLint, TypeScript
├── tooling/
│   └── eslint/       # Pacote de regras personalizadas
├── turbo.json
├── package.json
└── pnpm-workspace.yaml

O arquivo turbo.json é o coração da configuração:

{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["**/.env.*local"],
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "dist/**", "!.next/cache/**"]
    },
    "test": {
      "dependsOn": ["build"],
      "inputs": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts"]
    },
    "lint": {
      "outputs": []
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

A integração com workspaces é feita no package.json raiz:

{
  "private": true,
  "workspaces": ["apps/*", "packages/*", "tooling/*"],
  "scripts": {
    "dev": "turbo run dev",
    "build": "turbo run build",
    "test": "turbo run test"
  },
  "devDependencies": {
    "turbo": "^1.10.0"
  }
}

3. Pipeline de build: otimizando o fluxo de trabalho

Definir tarefas corretamente no pipeline é crucial. Cada tarefa pode ter dependências e outputs específicos:

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"],
      "inputs": ["src/**", "tsconfig.json"]
    },
    "typecheck": {
      "dependsOn": ["^build"],
      "outputs": []
    },
    "deploy": {
      "dependsOn": ["build", "test"],
      "outputs": [],
      "cache": false
    }
  }
}

O cache remoto é um dos maiores diferenciais. Com a Vercel, a configuração é simples:

# .env
TURBO_TOKEN=seu_token_vercel
TURBO_TEAM=sua_equipe
TURBO_REMOTE_CACHE_SIGNATURE_KEY=chave_opcional

Em CI, isso reduz o tempo de build de 15 minutos para 2 minutos quando apenas pacotes específicos são alterados.

4. Gerenciamento de dependências entre pacotes

O Turborepo usa o sistema de workspaces do gerenciador de pacotes (npm, yarn, pnpm) para resolver dependências internas. A configuração de dependências entre pacotes segue o padrão:

// packages/ui/package.json
{
  "name": "@meuapp/ui",
  "dependencies": {
    "@meuapp/utils": "workspace:*",
    "react": "^18.0.0"
  }
}

O dependsOn no turbo.json controla a ordem de execução:

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"]
    }
  }
}

O símbolo ^ indica que a tarefa build de um pacote depende da tarefa build de todos os seus pacotes dependentes. Sem ele, as tarefas seriam executadas em paralelo, causando erros.

Para evitar o "dependency hell", mantenha um único lockfile (gerado pelo pnpm ou yarn) e use versionamento semântico rigoroso nos pacotes internos.

5. Trabalhando em equipe: boas práticas de colaboração

A padronização de scripts é essencial em equipe:

// package.json raiz
{
  "scripts": {
    "dev:web": "turbo run dev --filter=@meuapp/web",
    "dev:api": "turbo run dev --filter=@meuapp/api",
    "test:changed": "turbo run test --filter=[HEAD^1]",
    "lint:all": "turbo run lint"
  }
}

Para hooks de pré-commit com Husky e lint-staged:

// .husky/pre-commit
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

npx turbo run lint test --filter=[HEAD^1]
// lint-staged.config.js
module.exports = {
  "*.{ts,tsx}": ["eslint --fix", "prettier --write"]
}

Essa configuração garante que apenas os pacotes alterados sejam verificados, mantendo o fluxo rápido mesmo em monorepos grandes.

6. Deploy e CI/CD com Turborepo

Um pipeline de CI eficiente usa o cache remoto e filtros inteligentes:

# .github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 2

      - uses: pnpm/action-setup@v2
        with:
          version: 8

      - uses: actions/setup-node@v3
        with:
          node-version: 18
          cache: 'pnpm'

      - run: pnpm install

      - name: Turbo Cache
        uses: actions/cache@v3
        with:
          path: .turbo
          key: ${{ runner.os }}-turbo-${{ github.sha }}
          restore-keys: |
            ${{ runner.os }}-turbo-

      - run: pnpm turbo run build test --filter=[HEAD^1]
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ secrets.TURBO_TEAM }}

Para deploy seletivo, use scripts que verificam quais pacotes foram alterados:

#!/bin/bash
# scripts/deploy-changed.sh
CHANGED_PACKAGES=$(pnpm turbo run build --filter=[HEAD^1] --dry-run=json | jq -r '.packages[]')

for PACKAGE in $CHANGED_PACKAGES; do
  if [[ $PACKAGE == @meuapp/web ]]; then
    vercel deploy --prod
  elif [[ $PACKAGE == @meuapp/api ]]; then
    railway up
  fi
done

7. Armadilhas comuns e como evitá-las

Problema com hoisting: dependências conflitantes entre pacotes podem causar erros misteriosos.

Solução: use pnpm com shamefully-hoist=false no .npmrc:

# .npmrc
shamefully-hoist=false
strict-peer-dependencies=false

Cache quebrado por inputs incorretos: se você não especificar corretamente os inputs, o Turborepo pode não detectar mudanças.

// Errado: inputs muito amplos
"build": {
  "inputs": ["**"]
}

// Correto: específico
"build": {
  "inputs": ["src/**", "tsconfig.json", "package.json"]
}

Outputs mal configurados: pastas de cache do Next.js (.next/cache) devem ser excluídas:

"outputs": [".next/**", "!.next/cache/**"]

Quando Turborepo não é a melhor escolha: para projetos com menos de 3 pacotes ou equipes muito pequenas, a complexidade adicional pode não valer a pena. Nesses casos, Yarn Workspaces puro ou até mesmo repositórios separados são mais adequados.

O Turborepo brilha quando você precisa escalar um monorepo Node.js com dezenas de pacotes e múltiplos times trabalhando simultaneamente. Com cache inteligente, paralelismo e configuração simples, ele transforma o caos de dependências em um fluxo de trabalho previsível e rápido.

Referências