Todos los posts
ReactReact NativeTypeScriptTurborepoMonorepoArquitectura

Arquitectura frontend escalable: React, React Native, TypeScript estricto y Turborepo

Cómo diseñar un monorepo que comparte código real entre web y mobile, mantiene TypeScript estricto en todos los paquetes, y no colapsa bajo su propio peso. Retos reales, decisiones reales, errores incluidos.

20 de abril de 20268 min de lectura

Hay una fantasía que circula en el ecosistema JavaScript: el monorepo como solución universal. Un repo, un pipeline, código compartido infinito, builds coordinados, y todos vivieron felices para siempre.

La realidad es más interesante. El monorepo resuelve problemas reales y crea otros que nadie mencionó en el blogpost que te convenciste de hacerlo. Este artículo es sobre ambos lados.

📦 monorepoTurborepo📁apps/📁packages/webNext.js 15mobileExpo / RNdomaintipos · lógica puraconfigtsconfig · eslinttipos · validaciones · constantes compartidas⚡ turbo run build
Un único repo, múltiples plataformas — código compartido donde tiene sentido

El problema que te lleva a un monorepo

El escenario clásico: tenés una aplicación web en React y una app mobile en React Native. Comparten dominio de negocio, tipos, lógica de validación, constantes, utilidades. Pero viven en repos separados.

Lo que pasa con el tiempo:

La fragmentación tiene un coste que se paga en bugs difíciles de rastrear y en velocidad de entrega que decrece con el tiempo.


Por qué Turborepo y no las alternativas

Nx es potente pero opinionado hasta el punto de sentirse como adoptar un framework, no una herramienta. Si tu equipo no tiene experiencia previa con él, el overhead de aprendizaje es real.

Lerna tuvo su momento. Hoy está principalmente en modo mantenimiento.

pnpm workspaces solo funciona bien para repos sencillos, pero sin caching inteligente el tiempo de build crece linealmente con el tamaño del monorepo.

Turborepo hace una cosa y la hace bien: cachea outputs de tasks y paraleliza builds respetando el grafo de dependencias. Es agnóstico a frameworks, rápido de configurar y tiene muy poca opinión sobre cómo estructurás tus paquetes.

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "dist/**"]
    },
    "typecheck": {
      "dependsOn": ["^typecheck"]
    },
    "lint": {}
  }
}

La clave es "dependsOn": ["^build"]: cuando construís la app web, Turborepo construye primero todos los paquetes del workspace de los que depende, en el orden correcto, con cache.


La estructura que funciona

Después de varios intentos, la estructura que mejor escala es:

apps/
  web/          # Next.js
  mobile/       # Expo / React Native

packages/
  ui/           # componentes compartibles (solo los que realmente se comparten)
  domain/       # tipos, validaciones, lógica de negocio pura
  config/       # tsconfig base, eslint base
  utils/        # utilidades sin dependencias de plataforma

Lo que se puede compartir (y lo que no)

Esta es la lección más importante: no todo debería compartirse.

Compartir sin problema:

No compartir (aunque duela):

El error más común es intentar compartir componentes UI y terminar con una capa de abstracción que no sirve bien a ninguna de las dos plataformas.


TypeScript estricto como contrato entre paquetes

En un monorepo, TypeScript deja de ser una herramienta de calidad individual y se convierte en el contrato entre paquetes. Esto tiene implicaciones importantes.

Una tsconfig base para gobernarlos a todos

// packages/config/tsconfig/base.json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "exactOptionalPropertyTypes": true,
    "verbatimModuleSyntax": true,
    "moduleResolution": "bundler",
    "target": "ES2022",
    "lib": ["ES2022"]
  }
}

Cada paquete extiende esta base y añade lo específico de su contexto:

// apps/web/tsconfig.json
{
  "extends": "@repo/config/tsconfig/nextjs.json",
  "include": ["src", "next.config.ts"]
}

El problema de noUncheckedIndexedAccess

Esta opción, que recomiendo activar, hace que array[0] tenga tipo T | undefined en lugar de T. Correcto desde el punto de vista de tipos, pero rompe código que asumía que el primer elemento siempre existe.

En un monorepo, cuando activás esto en la base, rompe todos los paquetes a la vez. La estrategia: activarlo package por package, empezando por los que tienen menos superficie de código.

Tipos compartidos vs contratos de API

Hay una tentación de poner los tipos del API response directamente en packages/domain. El problema: cuando el backend cambia, rompés el build del frontend antes de que el cambio llegue a producción.

Lo que funciona mejor: tipos de dominio en packages/domain, tipos de API response generados automáticamente desde OpenAPI/GraphQL schema. Así el contrato es el schema del API, no un tipo escrito a mano que puede desincronizarse.


Los retos reales que nadie menciona

1. El caché que miente

El caché de Turborepo es potente y a veces demasiado agresivo. Si una variable de entorno cambia pero no está declarada en env del turbo.json, el caché no se invalida y usás un build viejo con la config nueva.

// turbo.json
{
  "tasks": {
    "build": {
      "env": ["NODE_ENV", "NEXT_PUBLIC_API_URL", "EXPO_PUBLIC_API_URL"],
      "outputs": [".next/**", "dist/**"]
    }
  }
}

Regla: cualquier variable de entorno que afecte el output debe estar declarada explícitamente.

2. Hot reload entre paquetes

En desarrollo, cuando modificás un archivo en packages/domain, querés que apps/web y apps/mobile lo reflejen de inmediato sin tener que buildear el paquete.

La solución: configurar los paquetes compartidos para que en desarrollo exporten desde el source directamente, no desde el build.

// packages/domain/package.json
{
  "exports": {
    ".": {
      "development": "./src/index.ts",
      "default": "./dist/index.js"
    }
  }
}

Esto requiere que los bundlers de cada app soporten imports TypeScript directos (Next.js sí, Expo con Babel sí).

3. El monorepo que cometió demasiado early

El error que he visto más frecuentemente: convertir todo en un paquete compartido desde el día uno, antes de saber qué realmente necesita ser compartido.

La consecuencia: paquetes con un solo consumer, abstracciones que complican más de lo que simplifican, y una arquitectura que no refleja los límites reales del dominio.

Regla práctica: algo entra en packages/ cuando tiene dos consumers confirmados. No antes. La duplicación temporal es preferible a la abstracción prematura en un contexto donde los límites todavía no son claros.

4. CI que tarda lo mismo que sin monorepo

Si el CI no está configurado para aprovechar el caché remoto de Turborepo, cada pipeline buildea todo desde cero. El monorepo empeora las cosas porque hay más para buildear.

# .github/workflows/ci.yml
- name: Setup Turborepo remote cache
  uses: dtinth/setup-turbo-cache@v1
  with:
    token: ${{ secrets.TURBO_TOKEN }}
    team: ${{ secrets.TURBO_TEAM }}

Con caché remoto, un PR que solo toca la app web no rebuildeará los paquetes que no cambiaron.


Atomic Design en un monorepo: dónde viven las capas

En el contexto de un monorepo con web y mobile, la pregunta es: ¿los átomos y moléculas van en el paquete compartido?

Mi respuesta después de haberlo intentado de ambas formas: no.

Los átomos y moléculas son la capa más acoplada a la plataforma (estilos, interacciones, accesibilidad). Intentar compartirlos crea componentes que no son realmente útiles en ninguna de las dos plataformas.

Lo que funciona: cada app tiene su propia implementación de las capas UI (atoms, molecules, organisms). El código compartido vive exclusivamente en packages/domain y packages/utils, que no tienen nada de UI.

La consistencia visual entre plataformas se mantiene a través de design tokens (colores, tipografía, espaciado) compartidos como constantes TypeScript, no como componentes.


Lo que haría diferente

Mirando hacia atrás, los cambios que harían más diferencia:

  1. Empezar con strict: true desde el día cero — añadirlo después crea una deuda técnica dolorosa de pagar
  2. Definir los límites del dominio antes de definir la estructura de paquetes — la estructura debe reflejar los límites, no al revés
  3. Configurar el caché remoto antes del primer merge a main — no después cuando los builds ya tardan 10 minutos
  4. Resistir la tentación de compartir UI entre plataformas — el time-to-market de hacerlo bien supera el beneficio en la mayoría de los casos

Cuándo no usar un monorepo

Para ser honesto: si tenés un equipo pequeño trabajando en una sola plataforma, el monorepo añade complejidad sin beneficio proporcional.

El punto de inflexión donde el monorepo empieza a pagar su overhead: dos o más plataformas que comparten dominio, o varios equipos trabajando en paquetes con dependencias entre sí que necesitan coordinación explícita.

Si no estás en ese punto, un repo bien organizado con buenas convenciones te llevará más lejos con menos fricción.


La arquitectura no es el fin. Es el medio para que el equipo pueda moverse rápido con confianza, mantener lo que construyó, y cambiar lo que necesite cambiar sin miedo. Un monorepo bien configurado lo facilita. Uno mal configurado lo obstaculiza.

La diferencia está en los detalles que nadie documenta hasta que los ha pagado en velocidad perdida.