DEV Community

Cover image for pnpm workspaces en monorepo con Next.js 16: lo que el benchmark no midió y casi me rompe el CI
Juan Torchia
Juan Torchia Subscriber

Posted on • Originally published at juanchi.dev

pnpm workspaces en monorepo con Next.js 16: lo que el benchmark no midió y casi me rompe el CI

pnpm workspaces en monorepo con Next.js 16: lo que el benchmark no midió y casi me rompe el CI

En 1994, cuando mi viejo me trajo la Amiga 500, yo no sabía nada de benchmarks. Sabía que si el disco tardaba mucho en cargar, algo estaba mal. No tenía métricas formales — tenía paciencia finita y un problema concreto en frente. Treinta años después, cuando publiqué el benchmark de pnpm vs npm vs yarn en mi monorepo, tenía números prolijos: install time, disk usage, cold cache vs warm cache. Bonito. Publicable. Y completamente ciego a lo que vino después.

Porque el benchmark midió install. No midió lo que pasa cuando pnpm workspaces y Next.js 16 App Router se encuentran en un CI con cache parcial y packages compartidos entre workspaces. Eso no se ve en un script de bash cronometrado en tu máquina local. Eso se ve cuando el pipeline de Railway tira un error críptico a las 11pm y el build lleva 18 minutos sin terminar.

Mi tesis es esta: pnpm workspaces sigue siendo la mejor opción para monorepos en 2026, pero tiene edge cases de hoisting que no aparecen en ningún benchmark de install time y que pueden costarte horas de debugging en CI si no sabés exactamente qué configuración aplicar con Next.js 16 App Router. No son bugs de pnpm — son consecuencias documentadas del modelo de aislamiento estricto que hace a pnpm superior en otros aspectos. El problema es que la documentación oficial asume que leíste todo el contexto previo, y en CI ese supuesto falla.


El problema que el benchmark no midió: cache invalidation y hoisting en workspaces

Cuando corrí el benchmark original, la estructura era simple: un monorepo con dos apps y un package compartido. El script medía pnpm install desde cero y con cache. Los números eran buenos. Lo que no medí fue el comportamiento de pnpm en CI bajo estas condiciones combinadas:

  1. Un package @repo/ui compartido con componentes React
  2. Una app apps/web con Next.js 16 App Router que importa de @repo/ui
  3. GitHub Actions cacheando ~/.pnpm-store entre runs
  4. Railway como destino de deploy con su propio build step

El error que aparece en este escenario no es en pnpm install. Es en el build de Next.js, y el mensaje es suficientemente genérico como para hacerte perder tiempo buscando en el lugar equivocado:

Error: Cannot find module '@repo/ui/components/Button'
Require stack:
- /app/apps/web/.next/server/chunks/[turbopack]_root_of_the_server__[...].js
Enter fullscreen mode Exit fullscreen mode

Ese error, en este contexto, no es un problema de imports mal escritos. Es una consecuencia directa de cómo pnpm maneja el hoisting de dependencias en workspaces con node_modules anidados — y de cómo Next.js 16 Turbopack resuelve módulos de manera diferente a webpack.


Cómo funciona el hoisting en pnpm (y por qué rompe en este caso)

La documentación oficial de pnpm workspaces (pnpm.io/workspaces) explica el modelo: a diferencia de npm y yarn, pnpm no hace hoisting agresivo por defecto. Cada package en el workspace tiene sus propias dependencias en su propio node_modules, y los packages compartidos se resuelven via symlinks hacia el store global.

En teoría, esto es exactamente lo que querés. En práctica, hay un edge case específico con Next.js 16 y Turbopack:

Turbopack resuelve módulos siguiendo el algoritmo de Node.js, que a su vez sigue las rutas de node_modules hacia arriba en el árbol de directorios. Cuando @repo/ui tiene una dependencia que también está declarada en apps/web pero en una versión diferente (aunque compatible según semver), pnpm crea dos instancias en el store. Turbopack, durante el build en CI, puede terminar resolviendo la instancia incorrecta dependiendo del orden en que procesa los chunks.

El escenario concreto que reproduce el problema:

monorepo/
├── packages/
│   └── ui/
│       └── package.json  # "react": "^18.3.0"
├── apps/
│   └── web/
│       └── package.json  # "react": "^18.3.1"  ← versión patch diferente
└── pnpm-workspace.yaml
Enter fullscreen mode Exit fullscreen mode
# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'
Enter fullscreen mode Exit fullscreen mode

Con esta configuración y sin .npmrc explícito, pnpm puede instalar dos versiones de React en el store. En local generalmente no lo ves porque el warm cache resuelve consistentemente. En CI con cache parcial (el store está cacheado pero el lockfile cambió recientemente), el comportamiento es no determinístico.

Acá está el mecanismo exacto del problema:

# Corré esto en el root del monorepo para ver cuántas instancias de react tiene pnpm
pnpm why react --recursive

# Si ves algo así, tenés el problema:
# apps/web
# └── react 18.3.1
# packages/ui
# └── react 18.3.0  ← instancia diferente
Enter fullscreen mode Exit fullscreen mode

El número no es anecdótico: en un monorepo con 6 packages compartidos y 3 apps, es posible terminar con 11 instancias duplicadas de dependencias peer. Cada una ocupa espacio en el store y, más importante, puede causar resolución incorrecta en runtime durante el build de Next.js.


La solución: .npmrc con public-hoist-pattern y sincronización de peers

La fix documentada (pero enterrada) está en configurar correctamente el .npmrc en el root del monorepo. Hay dos enfoques y vale la pena entender cuál corresponde a cada caso.

Opción 1: shamefully-hoist=true — la solución nuclear

# .npmrc en el root del monorepo
shamefully-hoist=true
Enter fullscreen mode Exit fullscreen mode

Esto hace que pnpm se comporte como npm/yarn con hoisting agresivo. Resuelve el problema inmediatamente. Pero perdés el principal beneficio de pnpm: el aislamiento estricto de dependencias. Si el monorepo escala, vas a ver dependencias fantasma que funcionan en desarrollo pero no en producción. No recomiendo este camino salvo como diagnóstico temporal.

Opción 2: public-hoist-pattern — la solución quirúrgica

# .npmrc en el root del monorepo
# Hoisting selectivo: solo las deps que realmente necesitan vivir en el root
public-hoist-pattern[]=*react*
public-hoist-pattern[]=*react-dom*
public-hoist-pattern[]=*next*
public-hoist-pattern[]=@types/*
Enter fullscreen mode Exit fullscreen mode

Esto le dice a pnpm: "estas dependencias específicas siempre van al node_modules del root". Turbopack las encuentra en un lugar predecible, no importa qué workspace las declare. El resto de las dependencias mantiene el aislamiento estricto.

Opción 3: Sincronizar las versiones peer en el lockfile — la solución de raíz

La opción más limpia a largo plazo es eliminar las duplicaciones desde el origen:

// pnpm-workspace.yaml no alcanza  también necesitás esto en el root package.json
{
  "pnpm": {
    "overrides": {
      "react": "18.3.1",
      "react-dom": "18.3.1"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Con pnpm.overrides, forzás una única versión de React en todo el monorepo. pnpm la respeta en todos los workspaces y el store tiene una sola instancia. Es la combinación que mejor funciona en CI con cache: determinística, reproducible, y sin hoisting que comprometa el aislamiento.

Después de aplicar esta configuración, el comportamiento en GitHub Actions cambia de manera medible:

# .github/workflows/ci.yml — fragmento relevante
- name: Setup pnpm
  uses: pnpm/action-setup@v4
  with:
    version: 9

- name: Cache pnpm store
  uses: actions/cache@v4
  with:
    path: ~/.local/share/pnpm/store
    # Clave de cache que incluye el lockfile completo
    # Si el lockfile no cambió, el store completo está disponible
    key: pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
    restore-keys: |
      pnpm-store-

- name: Install dependencies
  run: pnpm install --frozen-lockfile
  # --frozen-lockfile es obligatorio en CI: falla si el lockfile está desactualizado
  # En lugar de actualizar silenciosamente y romper el cache de la próxima run
Enter fullscreen mode Exit fullscreen mode

La diferencia en tiempo de CI con la configuración correcta de overrides y cache key basada en el lockfile completo es considerable: un monorepo con 6 workspaces puede pasar de builds no determinísticos de 12-18 minutos a builds reproducibles de 4-6 minutos en runs con cache caliente. El ahorro no viene de instalar más rápido — viene de no tener que re-resolver el grafo de dependencias cuando el store tiene inconsistencias.


Los errores que te hacen perder tiempo buscando en el lugar equivocado

Después de diagnosticar este tipo de problema en diferentes configuraciones, estos son los tres patrones de error que más tiempo hacen perder porque parecen ser problemas de otra cosa:

Error 1: "Cannot find module" en build, no en dev

Module not found: Can't resolve '@repo/ui/components/Button'
Enter fullscreen mode Exit fullscreen mode

Este error solo aparece en next build, no en next dev. En desarrollo, Next.js usa el file system directamente con hot reload y evita el problema de resolución. En build, Turbopack construye el grafo completo y ahí es donde la doble instancia de React fuerza una ruta de resolución inconsistente. Si ves este error solo en CI, la causa casi segura es el hoisting.

Error 2: "Invalid hook call" en runtime después del build exitoso

Error: Invalid hook call. Hooks can only be called inside of a function component.
Enter fullscreen mode Exit fullscreen mode

Este es el más traicionero. El build termina sin errores, el deploy llega a Railway, y en runtime explota con un error de hooks. La causa es exactamente la misma: dos instancias de React en el bundle final. El componente del workspace @repo/ui usa la instancia A de React, la app apps/web usa la instancia B, y cuando un hook cruza ese límite, React no los reconoce como del mismo runtime.

La verificación es directa:

# Verificar que hay una sola instancia de React en el bundle
# Corré esto en el root después de pnpm install
ls apps/web/node_modules/react 2>/dev/null && echo "⚠️ React duplicado en apps/web"
ls packages/ui/node_modules/react 2>/dev/null && echo "⚠️ React duplicado en packages/ui"
# Si ninguno de estos directorios existe, React vive solo en el root node_modules — correcto
Enter fullscreen mode Exit fullscreen mode

Error 3: Cache invalidation silenciosa en Railway

Railway cachea el store de pnpm entre deploys, pero la key que usa por defecto no siempre incluye el lockfile completo. Si el lockfile cambió porque actualizaste una dependencia en un workspace, Railway puede restaurar un store que no corresponde al lockfile actual, y pnpm install --frozen-lockfile falla con un error de integridad que no dice nada útil sobre la causa real.

La solución es configurar explícitamente el cache en Railway usando una variable de entorno que invalide el cache cuando cambia el lockfile:

# En railway.json o como variable de entorno en Railway
RAILWAY_CACHE_KEY=$(sha256sum pnpm-lock.yaml | cut -d' ' -f1)
Enter fullscreen mode Exit fullscreen mode

FAQ: pnpm workspaces, Next.js 16 y CI

¿Por qué este problema no aparece en local pero sí en CI?

En local, el store de pnpm está caliente y es consistente porque lo construiste de forma acumulativa. En CI, el cache se restaura de forma parcial o desde una key desactualizada. La combinación de store parcial + lockfile actualizado + resolución de módulos por Turbopack genera condiciones de carrera en la resolución del grafo de dependencias que en local nunca se dan.

¿shamefully-hoist=true es una solución válida o solo un parche?

Es un parche válido para diagnóstico y para monorepos pequeños donde el aislamiento estricto no es prioritario. Para monorepos que escalan (más de 4-5 packages, equipos de más de 2 personas, dependencias que divergen entre workspaces), shamefully-hoist=true va a crear dependencias fantasma que solo vas a descubrir en producción. Usalo para confirmar que el problema es de hoisting, después aplicá public-hoist-pattern o pnpm.overrides.

¿pnpm.overrides afecta la resolución de dependencias transitivas?

Sí, y es exactamente para eso que existe. pnpm.overrides fuerza una versión específica de una dependencia en todo el árbol de dependencias, incluyendo las transitivas. Si @repo/ui tiene una dependencia que a su vez depende de React, pnpm.overrides garantiza que esa dependencia anidada también use la versión que especificás. Es el mecanismo correcto para controlar dependencias peer en monorepos.

¿Next.js 16 con Turbopack tiene diferencias específicas respecto a webpack en esto?

Sí. Turbopack tiene su propio resolver de módulos que no es 100% compatible con el comportamiento de webpack en casos edge. En particular, Turbopack puede memoizar rutas de resolución durante el build de una manera que webpack no hace, lo que hace que las inconsistencias del store de pnpm sean más fáciles de activar. Con la configuración de webpack clásica, muchos de estos casos pasan desapercibidos o producen warnings en lugar de errores fatales.

¿Cómo sé si mi cache de CI está generando builds no determinísticos?

Corrí el mismo commit dos veces en CI sin cambios y comparé los hashes de los chunks de Next.js en .next/static/chunks/. Si los nombres de los archivos cambian entre runs idénticos, tenés no-determinismo en la resolución. Un build determinístico produce exactamente los mismos chunk names para el mismo código fuente. Si hay diferencias, el primer candidato es el store de pnpm con inconsistencias entre la cache restaurada y el lockfile actual.

¿Este problema aplica solo a Next.js o a cualquier app en el monorepo?

El problema de hoisting aplica a cualquier framework en el workspace, pero Next.js con Turbopack lo hace más visible porque el proceso de build es más agresivo en la resolución del grafo completo de módulos. Remix, Vite, y otros builders pueden silenciar el error o producir warnings no fatales. Next.js con --frozen-lockfile y Turbopack tiende a fallar de manera ruidosa, que irónicamente es lo correcto — el problema existe en todos los casos, solo que Next.js lo hace imposible de ignorar.


Conclusión: el benchmark mide lo que medís, no lo que importa

Cuando publiqué el post original de pnpm vs npm vs yarn, el número más importante que medí fue install time. Tenía razón en que pnpm gana en velocidad y disk usage. Me equivoqué en asumir que esos números capturaban el costo total de trabajar con workspaces en CI.

El verdadero costo de pnpm workspaces no está en el install. Está en la configuración de .npmrc, en la sincronización de versiones peer, y en la key de cache que usás en GitHub Actions y Railway. Eso no aparece en ningún benchmark de script bash. Aparece a las 11pm cuando el CI lleva 18 minutos y el error dice "Cannot find module" pero el módulo está ahí, en el store, en dos versiones simultáneas que se pisotean entre sí.

Mi postura después de trabajar con esta configuración: pnpm workspaces + pnpm.overrides + public-hoist-pattern para React + cache key basada en el lockfile completo es la configuración correcta para monorepos con Next.js 16 en 2026. No es complicada una vez que la entendés. El problema es que nadie la documenta junta, en un solo lugar, con el contexto de por qué cada pieza importa.

La documentación oficial de pnpm (pnpm.io/workspaces) tiene todo lo necesario para armar esta configuración — pero espera que llegués con el contexto correcto. Este post es ese contexto.

Si estás evaluando el stack completo, los otros posts de esta serie son relevantes: el análisis de Spring Boot en Railway tiene el mismo patrón de "el default no es lo correcto para tu caso", y el post sobre functional programming en TypeScript toca cómo los patrones que sobreviven en producción son los que son verificables y no los que son elegantes en papel.

El monorepo va a seguir dando lecciones. Las próximas las voy a medir mejor.


Fuentes originales:


Este artículo fue publicado originalmente en juanchi.dev

Top comments (0)