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.
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.
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:
- Los tipos se desincronían. El backend actualiza un endpoint, el web lo adapta, el mobile se entera dos sprints después.
- La lógica de validación existe dos veces, evolucionando de forma divergente.
- Hacer un cambio que afecta a ambas plataformas implica dos PRs, dos pipelines, dos deploys coordinados manualmente.
- El conocimiento de qué hace exactamente cada parte vive en la cabeza de personas específicas, no en el código.
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:
- Tipos TypeScript (
User,Product,ApiResponse<T>) - Lógica de validación pura (
validateEmail,parseDate) - Constantes de dominio (
ROLES,STATUS_CODES) - Utilidades sin side effects (
formatCurrency,debounce) - Hooks de lógica de negocio pura (sin imports de
react-nativeni APIs de browser)
No compartir (aunque duela):
- Componentes con estilos: Tailwind no existe en React Native, StyleSheet de RN no existe en web
- Hooks que tocan APIs de plataforma (
AsyncStoragevslocalStorage) - Navegación: React Navigation ≠ Next.js Router
- Animaciones: Reanimated ≠ Framer Motion
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:
- Empezar con
strict: truedesde el día cero — añadirlo después crea una deuda técnica dolorosa de pagar - Definir los límites del dominio antes de definir la estructura de paquetes — la estructura debe reflejar los límites, no al revés
- Configurar el caché remoto antes del primer merge a main — no después cuando los builds ya tardan 10 minutos
- 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.