Logo Gerardo Perrucci - Full Stack Developer

Cómo Estructuro Proyectos Next.js para Escalar

Cómo Estructuro Proyectos Next.js para Escalar

En mis más de 12 años de ingeniería de software, he visto proyectos comenzar como repositorios limpios y manejables y degenerar lentamente en "código espagueti" mientras los plazos nos presionan para entregar más rápido. He sido testigo de esto a gran escala—trabajando en portales de alto tráfico para plataformas de gaming globales—y en entornos B2B fintech de ritmo acelerado.

El denominador común en proyectos exitosos y longevos no es solo el stack tecnológico (aunque TypeScript y Next.js son mis herramientas preferidas); es la disciplina de organización.

Anteriormente escribí sobre elegir entre App Router y Pages Router, recomendando App Router para aplicaciones complejas. Hoy, quiero ir un paso más allá: ¿Cómo organizas realmente esos archivos para que tu equipo no te odie dentro de seis meses?

Tabla de Contenidos

La Filosofía: Arquitectura Basada en Features

El tutorial por defecto de Next.js sugiere una estructura agrupada por "rol técnico": components/, hooks/, utils/. Esto funciona bien para un blog o un portfolio. Pero cuando estás construyendo un dashboard financiero complejo o una plataforma multi-tenant, este enfoque fractura tu lógica de dominio. Terminas saltando entre cinco carpetas diferentes solo para editar un modal de "Editar Usuario".

Para aplicaciones escalables, abogo por una Arquitectura Basada en Features.

Agrupamos archivos por dominio de negocio, no por tipo de archivo. Si elimino la carpeta features/invoices, debería saber con confianza que he eliminado todo lo relacionado con facturas, sin dejar hooks o funciones util huérfanas ensuciando el codebase.

La Estructura de Carpetas

Aquí está la estructura de alto nivel que uso para nuevos proyectos de nivel producción:

src/
├── app/                  # App Router: Capa delgada, estrictamente para routing
   ├── (public)/         # Grupos de rutas para separación de layouts
   ├── (dashboard)/
   ├── layout.tsx
   └── page.tsx
├── components/           # Biblioteca UI Compartida (El "Design System")
   ├── button/
   ├── modal/
   └── typography/
├── features/             # La lógica de negocio central
   ├── auth/
   ├── settings/
   └── financial-risk/   # Ejemplo de dominio de mi trabajo reciente
├── lib/                  # Configuraciones de librerías de terceros
   ├── axios.ts
   ├── query-client.ts
   └── utils.ts
└── types/                # Tipos globales (mantén esto mínimo)

1. Mantén app/ Delgado

El directorio app debería solo ser responsable de routing y layouts. Trato los archivos page.tsx como puntos de entrada que simplemente obtienen datos y renderizan un componente de feature.

❌ No hagas esto (en app/dashboard/page.tsx):

// ❌ Demasiada lógica en el route handler
export default function DashboardPage() {
  const [data, setData] = useState(null);
  // ... 50 líneas de useEffect y lógica de transformación
  return <div>{/* ... JSX complejo ... */}</div>;
}

✅ Haz esto en su lugar:

// ✅ punto de entrada distinto
import { DashboardOverview } from '@/features/dashboard/components/dashboard-overview';

export default function DashboardPage() {
  return <DashboardOverview />;
}

2. El Directorio features/

Aquí es donde ocurre el 90% de tu desarrollo. Cada carpeta de feature debería verse como una mini-aplicación.

src/features/financial-risk/
├── components/           # Componentes específicos SOLO para este feature
   ├── risk-chart.tsx
   └── risk-table.tsx
├── hooks/                # Hooks usados SOLO en este feature
   ├── use-risk-calculations.ts
├── api/                  # Lógica de obtención de datos para este dominio
   ├── get-risk-data.ts
└── types/                # Tipos específicos del dominio
    └── index.ts

3. El Directorio components/ (UI Compartida)

Este es tu "Design System" interno. A lo largo de mi carrera, incluyendo liderar equipos para grandes clientes empresariales, construir un design system robusto fue crítico para la consistencia.

Estos componentes deberían ser "tontos"—no deberían conocer tu lógica de negocio o llamadas API. Reciben props y emiten eventos.

Ejemplo de Código: Una Implementación de Feature Escalable

Veamos un ejemplo concreto usando TypeScript y TanStack Query (en el que confío mucho para server state).

Imagina un feature para mostrar una lista de transacciones financieras.

src/features/transactions/api/get-transactions.ts

import { axios } from '@/lib/axios'; // Instancia de axios centralizada
import { Transaction } from '../types';

export const getTransactions = async (): Promise<Transaction[]> => {
  const response = await axios.get('/transactions');
  return response.data;
};

src/features/transactions/components/transaction-list.tsx

'use client';

import { useQuery } from '@tanstack/react-query';
import { getTransactions } from '../api/get-transactions';
import { TransactionItem } from './transaction-item'; // Sub-componente co-localizado
import { Spinner } from '@/components/ui/spinner'; // Componente del design system compartido

export const TransactionList = () => {
  const { data, isLoading, isError } = useQuery({
    queryKey: ['transactions'],
    queryFn: getTransactions,
  });

  if (isLoading) return <Spinner />;
  if (isError) return <div>Failed to load transactions</div>;

  return (
    <ul className="space-y-4">
      {data?.map((transaction) => (
        <TransactionItem key={transaction.id} transaction={transaction} />
      ))}
    </ul>
  );
};

Gestión de Estado: El Cambio de "Server State"

Uno de los mayores errores que veo en apps React modernas es el uso excesivo de estado global del cliente (Redux, Zustand, Context) para datos que en realidad pertenecen al servidor.

Si los datos vienen de una API, es Server State. Usa TanStack Query (React Query) para manejar caché, deduplicación y revalidación.

Mi Regla de Decisión para Estado:

  1. ¿Viene de la BD? → Usa TanStack Query.
  2. ¿Es impulsado por URL (filtros, paginación)? → Usa URL Search Params (Next.js useSearchParams).
  3. ¿Es solo UI (está abierto el modal)? → Usa useState local.
  4. ¿Es UI global compleja (tema, sidebar colapsado)? → Solo entonces usa Context o Zustand.

¿Cuándo Abstraer? (La Regla de Dos)

Una trampa común es la "abstracción prematura"—crear un componente compartido para algo que solo se usa una vez.

Sigo una heurística simple: La Regla de Dos.

Decorative quote icon

No muevas un componente a src/components/ (compartido) hasta que se use en al menos DOS features diferentes.

Hasta entonces, mantenlo dentro de src/features/my-feature/components/. Código duplicado es mejor que la abstracción incorrecta. Es más fácil fusionar dos componentes similares más tarde que desmantelar un "God Component" gigante que intenta hacer todo para todos.

Resumen

Escalar Next.js tiene menos que ver con conocer cada opción de configuración y más con límites estrictos.

  • App Router para routing, no lógica.
  • Features folder para propiedad de dominio.
  • TanStack Query para server state.
  • Shared Components para tu design system.

Esta estructura me ha servido bien desde startups ágiles hasta entornos bancarios empresariales masivos. Mantiene el codebase descubrible y, lo más importante, permite que tu equipo se mueva rápido sin romper cosas.

Referencias

¿Tienes preguntas sobre este tema?

Agenda una llamada para discutir cómo puedo ayudar con tu proyecto

Cómo Estructuro Proyectos Next.js para Escalar | Desarrollador Full Stack - Gerardo Perrucci