Arquitectura modular por features en Next.js 15
Por qué organizo apps por dominio en vez de por tipo de archivo, y cómo se ve un módulo completo en la práctica.
Una pregunta que aparece seguido en code reviews es esta:
“¿Dónde pongo este archivo? ¿En
components/,hooks/, outils/?”
La respuesta corta: ninguno de esos. Esos son tipos de archivo. Y organizar una app por tipos de archivo es como ordenar una biblioteca por color de portada.
Por dominio, no por tipo
La estructura que uso hoy:
src/
├── app/ # Rutas (Next.js App Router)
├── features/ # Módulos por dominio
│ ├── students/
│ │ ├── components/ # ListView, DetailView, StudentForm
│ │ ├── api/ # use-students, mutations, query keys
│ │ ├── lib/ # validators, helpers específicos
│ │ └── types/
│ ├── enrollments/
│ ├── reports/
│ └── shared/ # Lo que cruza features (navbar, footer)
├── components/ui/ # Primitivos reutilizables (Button, Card)
├── lib/ # Utilidades globales (cn, formatDate)
└── hooks/ # Hooks 100% genéricos (useMediaQuery)
Cada feature es autocontenida. Si entras a features/students/, ves todo
lo de estudiantes: componentes, API hooks, validadores, tipos. No saltas entre
carpetas globales para entender un dominio.
Las dos reglas que hacen que funcione
1. Las features no se importan entre sí
features/students/ no importa de features/enrollments/. Si lo necesita, la
lógica compartida sube a shared/ o se rompe el acoplamiento con un evento o
callback inyectado.
Esta regla, aunque suena estricta, es la que mantiene el codebase navegable a 1 año. Sin ella, terminas con un grafo de dependencias donde tocar un componente rompe tres features.
2. components/ui/ es solo design system
Button, Card, Badge, Input. Sin lógica de negocio. Sin referencia a
entidades del dominio. Si tu <UserCard> está en components/ui/, está en el
lugar incorrecto — va a features/users/components/UserCard.tsx.
¿Y los hooks?
useMediaQuery,useDebounce,useMounted→src/hooks/(genéricos)useStudent,useEnrollmentForm→features/<feature>/api/ofeatures/<feature>/lib/
La pregunta para decidir es: ¿este hook menciona el dominio? Si sí, va dentro de la feature. Si no, es genérico.
Por qué Next.js App Router empuja en esta dirección
Con App Router y Server Components, el “qué pertenece a este archivo” es más
estricto: page.tsx es solo renderizado de ruta, los Server Components no pueden
usar hooks de cliente, los "use client" deben ser explícitos. Esto choca con
organizar por tipo: terminas con archivos largos que mezclan SSR + client +
data fetching.
Cuando organizas por feature, cada feature decide su propia frontera client/server:
features/students/
├── components/
│ ├── student-list.tsx # Server Component (lee de la DB)
│ ├── student-form.tsx # "use client" (formularios interactivos)
│ └── student-filters.tsx # "use client" (input controlado)
└── api/
├── get-students.ts # Server-side (acceso DB)
└── use-students.ts # Client-side (TanStack Query)
Lo que la modularidad NO te da gratis
- No es DDD. No estoy hablando de bounded contexts ni de eventos de dominio. Es estructura física de archivos. La modelación del dominio es otra conversación.
- No reemplaza a TypeScript estricto. Si las features se importan entre sí vía
any, el compilador no te avisa. - No funciona si la app es muy pequeña. Para landing pages o sites de documentación, organizar por features es overkill. Aplícalo cuando ya hay 3+ dominios reales.
La señal de que está funcionando
La métrica que más miro es simple: ¿cuántos archivos toco para añadir una
feature nueva? Con la app organizada por tipo, una feature nueva se esparce por
varias carpetas globales. Organizada por dominio, casi todo vive en la misma
carpeta features/<feature>/. Esa diferencia es lo que el equipo siente como
“el codebase escala”.
Esta es la arquitectura a la que migré el frontend en mi trabajo actual. Si te interesa la capa de datos, mira reducir requests con TanStack Query.