Voltar ao blog
8 de junho de 20264 min de leitura

Monorepo com npm workspaces e Turbo: o que ninguém te conta sobre React 19, hoisting e shared packages

monorepotypescriptnextjsdevops

Semana passada precisei resolver um problema que parecia simples: fazer um frontend Next.js consumir tipos TypeScript de um pacote shared, que também era usado pelo backend pipeline. Os dois viviam no mesmo repositório. Deveria ser trivial.

Não foi.

O que aprendi nesse processo sobre npm workspaces, Turborepo e os edge cases de monorepos com versões conflitantes de React é o que quero compartilhar aqui. Não como tutorial passo-a-passo, mas como registro do que realmente acontece quando você tenta fazer isso funcionar em produção.

O cenário

O projeto é um data warehouse de dívida pública brasileira. Três apps num monorepo:

  • apps/pipeline — API REST + ETL (Node.js puro, sem React)
  • apps/web — Frontend Next.js (React 19)
  • apps/pdf-worker — Gerador de PDF (Playwright, sem React)

E um pacote compartilhado:

  • packages/shared — tipos TypeScript, schemas Zod, constantes

A ideia é que os três apps importem de @dw-divida/shared sem duplicar código. O package.json raiz declara os workspaces:

{
  "workspaces": ["apps/*", "packages/*"]
}

Turborepo orquestra os scripts. Tudo lindo no papel.

O primeiro problema: duas versões do React

O apps/web usa React 19. O apps/pdf-worker tem uma dependência (resend) que puxa @react-email/render, que depende de React 18. Quando roda npm install na raiz, o npm hoista React 18 pro node_modules raiz (porque foi a primeira versão encontrada) e instala React 19 localmente em apps/web/node_modules.

O resultado: @testing-library/react (hoistado na raiz) resolve o React 18 da raiz, mas os componentes do web usam o React 19 local. Duas instâncias do React no mesmo teste. O useState quebra com "Cannot read properties of null".

A correção que funcionou: overrides no package.json raiz forçando React 19 pra todo o workspace do web:

{
  "overrides": {
    "divida-publica-dashboard": {
      "react": "19.2.4",
      "react-dom": "19.2.4"
    }
  }
}

Depois de limpar node_modules e reinstalar, o npm entendeu que dentro do contexto do web, tudo precisa usar React 19. O React 18 ficou isolado no escopo do pdf-worker, onde deveria estar.

Lição: overrides por workspace é a ferramenta certa quando você tem conflitos de peer dependencies entre apps. Não tente resolver com aliases no bundler o problema é no resolver do Node.js, não no build.

O segundo problema: o shared package não resolve em runtime

O packages/shared tinha "main": "./src/index.ts" no package.json. Isso funciona perfeitamente em desenvolvimento o TypeScript resolve o arquivo fonte diretamente via project references e o bundler do Next.js transpila.

Em produção, dentro de um container Docker, o pipeline compila pra JavaScript com tsc. O output vai pra dist/. Mas o main ainda aponta pra src/index.ts, que não existe no runtime image (só copiamos o dist/).

O Node.js tenta importar @dw-divida/shared, resolve pra packages/shared/src/index.ts, e explode com "Cannot find module".

A solução pragmática que usei: copiar o src/ inteiro do shared pro container e usar tsx no entrypoint pra resolver TypeScript em runtime. Não é o mais elegante, mas funciona sem complicar o build:

COPY --from=build /app/packages/shared ./packages/shared
CMD ["tsx", "dist/index.js"]

A solução "correta" seria compilar o shared pra JS com extensões .js nos imports e manter "main": "./dist/index.js". Mas isso exige configurar o tsconfig do shared com moduleResolution: "node16" e adicionar extensões em todos os imports internos. Pra um pacote privado num monorepo, o pragmatismo venceu.

Turborepo: o que faz e o que não faz

O Turbo é um task runner com cache. Ele entende dependências entre pacotes (dependsOn: ["^build"]) e paraleliza o que pode. Pra esse projeto, o turbo.json é:

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"]
    },
    "dev": {
      "dependsOn": ["^build"],
      "cache": false,
      "persistent": true
    }
  }
}

O que ele faz bem: garante que o shared compila antes do pipeline, e que o build do Next.js tem acesso aos tipos atualizados.

O que ele não faz: gerenciar variáveis de ambiente. O turbo dev não passa env vars entre workspaces. Cada app precisa carregar seu próprio .env. Isso me custou uma hora de debug quando o pipeline não encontrava o token do Mercado Pago o arquivo .env estava na raiz, mas o pipeline só procurava no próprio diretório.

A correção:

import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(process.cwd(), '../../.env') });

Dois pontos pro caminho relativo. Nenhuma ferramenta no ecossistema resolve isso automaticamente de forma satisfatória.

O output do Next.js e o cache do Turbo

O turbo.json original tinha "outputs": ["dist/**"] no build. O Next.js gera output em .next/, não em dist/. Resultado: o cache do Turbo nunca invalidava corretamente o build do web. Sempre reconstruía do zero.

Correção óbvia mas fácil de esquecer:

"outputs": ["dist/**", ".next/**"]

transpilePackages: quando o bundler precisa de ajuda

O Next.js não transpila pacotes de node_modules por padrão. O shared package é linkado via workspace (symlink em node_modules/@dw-divida/shared → ../../packages/shared), mas o Next.js trata como dependência externa e não passa pelo Babel/SWC.

Se o shared exporta TypeScript puro (sem compilar pra JS antes), o Next.js precisa saber que deve transpilar:

const nextConfig: NextConfig = {
  transpilePackages: ["@dw-divida/shared"],
};

Sem isso: "Cannot use import statement outside a module" em produção.

O que eu faria diferente

Se começasse de novo:

  1. O shared package teria build próprio gerando ESM com extensões .js. Sem depender de tsx em runtime.
  2. Usaria pnpm em vez de npm. O modelo de hoisting do pnpm (symlinks estritos) evita a maioria dos conflitos de versão que tive com React 18/19.
  3. Testaria o Docker build no CI desde o primeiro dia. Descobri problemas de resolução de módulos só quando deployei tarde demais pra corrigir sem pressa.

Considerações Finais

Monorepos com npm workspaces funcionam. Não são mágicos você vai bater de frente com conflitos de hoisting, resolução de módulos em runtime vs build, e limitações de tooling que assume um único package.json.

O Turborepo resolve bem o problema de orquestração (build order, cache, paralelismo), mas não resolve os problemas de resolução de módulos. Esses continuam sendo do npm + Node.js + seu bundler.

A combinação que sobreviveu em produção: npm workspaces + overrides por workspace + transpilePackages + tsx no runtime do backend + .env com path relativo. Não é bonito. Funciona.