Blog
4 min de lectura

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.

Next.js Arquitectura TypeScript Patterns

Una pregunta que aparece seguido en code reviews es esta:

“¿Dónde pongo este archivo? ¿En components/, hooks/, o utils/?”

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, useMountedsrc/hooks/ (genéricos)
  • useStudent, useEnrollmentFormfeatures/<feature>/api/ o features/<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.