Saltar al contenido
·11 min de lectura

Estructurando Proyectos Next.js a Escala: Lo Que Aprendí a las Malas

Lecciones duramente ganadas sobre como organizar bases de código Next.js que no colapsan bajo su propio peso. Arquitectura basada en features, route groups, limites servidor/cliente, trampas de barrel files y una estructura de carpetas real que puedes copiar.

Compartir:X / TwitterLinkedIn

Todo proyecto Next.js empieza limpio. Unas cuantas páginas, una carpeta de componentes, quiza un lib/utils.ts. Te sientes productivo. Te sientes organizado.

Luego pasan seis meses. Se unen tres desarrolladores. La carpeta de componentes tiene 140 archivos. Alguien creo utils2.ts porque utils.ts tenia 800 líneas. Nadie sabe que layout envuelve a que ruta. Tu build tarda cuatro minutos y no estas seguro de por que.

He pasado por este ciclo multiples veces. Lo que sigue es lo que realmente hago ahora, después de aprender la mayoria de estas lecciones lanzando primero lo incorrecto.

La Estructura Por Defecto Se Desmorona Rápido#

La documentación de Next.js sugiere una estructura de proyecto. Esta bien. Funciona para un blog personal o una herramienta pequeña. Pero en el momento en que mas de dos desarrolladores tocan la misma base de código, el enfoque "basado en tipos" empieza a agrietarse:

src/
  components/
    Button.tsx
    Modal.tsx
    UserCard.tsx
    DashboardSidebar.tsx
    InvoiceTable.tsx
    InvoiceRow.tsx
    InvoiceActions.tsx
    ...137 more files
  hooks/
    useAuth.ts
    useInvoice.ts
    useDebounce.ts
  utils/
    format.ts
    validate.ts
    misc.ts     <- el principio del fin
  app/
    page.tsx
    dashboard/
      page.tsx

El problema no es que esto sea "incorrecto". Es que escala linealmente con las funcionalidades pero no con la comprension. Cuando un nuevo desarrollador necesita trabajar en facturas, tiene que abrir components/, hooks/, utils/, types/ y app/ simultaneamente. La funcionalidad de facturacion esta dispersa en cinco directorios. No hay un solo lugar que diga "así es como se ve la facturacion".

Vi a un equipo de cuatro desarrolladores pasar un sprint completo refactorizando hacia esta estructura exacta, convencidos de que era la forma "limpia". En tres meses estaban de vuelta a estar confundidos, solo que con mas carpetas.

La Colocacion Basada en Features Gana. Siempre.#

El patrón que realmente sobrevivio al contacto con un equipo real es la colocacion basada en features. La idea es simple: todo lo relacionado con una funcionalidad vive junto.

No "todos los componentes en una carpeta y todos los hooks en otra". Lo opuesto. Todos los componentes, hooks, tipos y utilidades para facturacion viven dentro de features/invoicing/. Todo lo de autenticación vive dentro de features/auth/.

typescript
// features/invoicing/hooks/use-invoice-actions.ts
import { useCallback } from "react";
import { Invoice } from "../types";
import { formatInvoiceNumber } from "../utils/format";
import { invoiceApi } from "../api/client";
 
export function useInvoiceActions(invoice: Invoice) {
  const markAsPaid = useCallback(async () => {
    await invoiceApi.updateStatus(invoice.id, "paid");
  }, [invoice.id]);
 
  const duplicate = useCallback(async () => {
    const newNumber = formatInvoiceNumber(Date.now());
    await invoiceApi.create({ ...invoice, number: newNumber, status: "draft" });
  }, [invoice]);
 
  return { markAsPaid, duplicate };
}

Observa los imports. Son todos relativos, todos dentro de la feature. Este hook no busca en alguna carpeta global hooks/ ni en un utils/format.ts compartido. Usa la lógica de formateo propia de la feature de facturacion porque el formateo de numeros de factura es una responsabilidad de facturacion, no global.

La parte contraintuitiva: esto significa que tendras algo de duplicacion. Tu feature de facturacion podría tener un format.ts y tu feature de facturacion también podría tener un format.ts. Eso esta bien. De hecho es mejor que un formatters.ts compartido del que dependen 14 features y que nadie puede cambiar de forma segura.

Me resisti a esto durante mucho tiempo. DRY era un dogma. Pero he visto como los archivos de utilidades compartidos se convierten en los archivos mas peligrosos de una base de código, archivos donde un "pequeño arreglo" para una feature rompe otras tres. La colocacion con duplicacion controlada es mas mantenible que el compartir forzado.

La Estructura de Carpetas Qué Realmente Uso#

Aquí esta el arbol completo. Esto ha sido probado en batalla en dos aplicaciones en producción con equipos de 4 a 8 personas:

src/
├── app/
│   ├── (public)/
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   ├── pricing/
│   │   │   └── page.tsx
│   │   └── blog/
│   │       ├── page.tsx
│   │       └── [slug]/
│   │           └── page.tsx
│   ├── (auth)/
│   │   ├── layout.tsx
│   │   ├── login/
│   │   │   └── page.tsx
│   │   └── register/
│   │       └── page.tsx
│   ├── (dashboard)/
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   ├── invoices/
│   │   │   ├── page.tsx
│   │   │   ├── [id]/
│   │   │   │   └── page.tsx
│   │   │   └── new/
│   │   │       └── page.tsx
│   │   └── settings/
│   │       └── page.tsx
│   ├── api/
│   │   └── webhooks/
│   │       └── stripe/
│   │           └── route.ts
│   ├── layout.tsx
│   └── not-found.tsx
├── features/
│   ├── auth/
│   │   ├── components/
│   │   │   ├── login-form.tsx
│   │   │   ├── register-form.tsx
│   │   │   └── oauth-buttons.tsx
│   │   ├── actions/
│   │   │   ├── login.ts
│   │   │   └── register.ts
│   │   ├── hooks/
│   │   │   └── use-session.ts
│   │   ├── types.ts
│   │   └── constants.ts
│   ├── invoicing/
│   │   ├── components/
│   │   │   ├── invoice-table.tsx
│   │   │   ├── invoice-row.tsx
│   │   │   ├── invoice-form.tsx
│   │   │   └── invoice-pdf.tsx
│   │   ├── actions/
│   │   │   ├── create-invoice.ts
│   │   │   └── update-status.ts
│   │   ├── hooks/
│   │   │   └── use-invoice-actions.ts
│   │   ├── api/
│   │   │   └── client.ts
│   │   ├── utils/
│   │   │   └── format.ts
│   │   └── types.ts
│   └── billing/
│       ├── components/
│       ├── actions/
│       ├── hooks/
│       └── types.ts
├── shared/
│   ├── components/
│   │   ├── ui/
│   │   │   ├── button.tsx
│   │   │   ├── input.tsx
│   │   │   ├── modal.tsx
│   │   │   └── toast.tsx
│   │   └── layout/
│   │       ├── header.tsx
│   │       ├── footer.tsx
│   │       └── sidebar.tsx
│   ├── hooks/
│   │   ├── use-debounce.ts
│   │   └── use-media-query.ts
│   ├── lib/
│   │   ├── db.ts
│   │   ├── auth.ts
│   │   ├── stripe.ts
│   │   └── email.ts
│   └── types/
│       └── global.ts
└── config/
    ├── site.ts
    └── navigation.ts

Algunas cosas a notar:

  • app/ solo contiene lógica de enrutamiento. Los archivos de página son delgados. Importan de features/ y componen. Un archivo de página debería tener 20-40 líneas, maximo.
  • features/ es donde vive el código real. Cada feature es autocontenida. Puedes eliminar features/invoicing/ y nada mas se rompe (excepto las páginas que importan de ella, que es exactamente el acoplamiento que quieres).
  • shared/ no es un cajon de sastre. Tiene subcategorias estrictas. Mas sobre esto abajo.

Route Groups: Mas Qué Solo Organización#

Los route groups, las carpetas entre (parentesis), son una de las funcionalidades mas subutilizadas del App Router. No afectan la URL en absoluto. (dashboard)/invoices/page.tsx se renderiza en /invoices, no en /dashboard/invoices.

Pero el verdadero poder es el aislamiento de layouts. Cada route group obtiene su propio layout.tsx:

typescript
// app/(dashboard)/layout.tsx
import { redirect } from "next/navigation";
import { getSession } from "@/shared/lib/auth";
import { Sidebar } from "@/shared/components/layout/sidebar";
 
export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await getSession();
 
  if (!session) {
    redirect("/login");
  }
 
  return (
    <div className="flex min-h-screen">
      <Sidebar user={session.user} />
      <main className="flex-1 p-6">{children}</main>
    </div>
  );
}
typescript
// app/(public)/layout.tsx
import { Header } from "@/shared/components/layout/header";
import { Footer } from "@/shared/components/layout/footer";
 
export default function PublicLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <>
      <Header />
      <main>{children}</main>
      <Footer />
    </>
  );
}

La verificación de autenticación ocurre una vez a nivel del layout, no en cada página. Las páginas publicas obtienen una estructura completamente diferente. Las páginas de autenticación no tienen layout alguno, solo una tarjeta centrada. Esto es limpio, explícito y difícil de arruinar.

He visto equipos poner verificaciones de autenticación dentro del middleware, dentro de páginas individuales, dentro de componentes, todo al mismo tiempo. Elige un lugar. Para la mayoria de las aplicaciones, el layout del route group es ese lugar.

La Carpeta shared/ No Es utils/#

La forma mas rápida de crear una pesadilla de mantenimiento es un archivo lib/utils.ts. Empieza pequeño. Luego se convierte en el cajon de sastre de la base de código, donde cada función que no tiene un hogar obvio termina.

Esta es mi regla: la carpeta shared/ requiere justificacion. Algo va a shared/ solo si:

  1. Es usado por 3 o mas features, y
  2. Es genuinamente generico (no lógica de negocio disfrazada de algo generico)

Una función de formateo de fechas que formatea fechas de vencimiento de facturas? Eso va en features/invoicing/utils/. Una función de formateo de fechas que toma un Date y devuelve un string de locale? Esa puede ir en shared/.

La subcarpeta shared/lib/ es especificamente para integraciones con terceros e infraestructura: clientes de base de datos, bibliotecas de autenticación, proveedores de pago. Son cosas de las que depende toda la aplicación pero que ninguna feature individual posee.

typescript
// shared/lib/db.ts — esto pertenece a shared
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";
 
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql);
 
// features/invoicing/utils/format.ts — esto NO pertenece a shared
export function formatInvoiceNumber(timestamp: number): string {
  const date = new Date(timestamp);
  const year = date.getFullYear();
  const seq = Math.floor(Math.random() * 9000) + 1000;
  return `INV-${year}-${seq}`;
}

Si estas a punto de usar shared/utils/ y no puedes nombrar el archivo con algo mas específico que helpers.ts, detente. Ese código probablemente pertenece dentro de una feature.

Componentes de Servidor vs. Cliente: Traza la Línea Deliberadamente#

Aquí hay un patrón que veo constantemente en bases de código que tienen problemas de rendimiento:

typescript
// Esto parece razonable pero es un error
"use client";
 
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
import { InvoiceFilters } from "@/features/invoicing/components/invoice-filters";
import { useInvoiceActions } from "@/features/invoicing/hooks/use-invoice-actions";
 
export default function InvoicesPage() {
  const { data } = useInvoiceActions();
  return (
    <div>
      <InvoiceFilters />
      <InvoiceTable invoices={data} />
    </div>
  );
}

La página esta marcada con "use client". Ahora todo lo que importa, InvoiceTable, InvoiceFilters, el hook, cada dependencia de cada dependencia, esta en el bundle del cliente. Tu tabla de 200 filas que podría haberse renderizado en el servidor con cero JavaScript ahora se esta enviando al navegador.

La solución es empujar "use client" hacia abajo, a los componentes hoja que realmente lo necesitan:

typescript
// app/(dashboard)/invoices/page.tsx — Server Component (sin directiva)
import { db } from "@/shared/lib/db";
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
import { InvoiceFilters } from "@/features/invoicing/components/invoice-filters";
 
export default async function InvoicesPage() {
  const invoices = await db.query.invoices.findMany({
    orderBy: (inv, { desc }) => [desc(inv.createdAt)],
  });
 
  return (
    <div>
      <InvoiceFilters />          {/* Client Component — tiene filtros interactivos */}
      <InvoiceTable data={invoices} /> {/* Server Component — solo renderiza filas */}
    </div>
  );
}
typescript
// features/invoicing/components/invoice-filters.tsx
"use client";
 
import { useRouter, useSearchParams } from "next/navigation";
 
export function InvoiceFilters() {
  const router = useRouter();
  const params = useSearchParams();
 
  const setStatus = (status: string) => {
    const next = new URLSearchParams(params.toString());
    next.set("status", status);
    router.push(`?${next.toString()}`);
  };
 
  return (
    <div className="flex gap-2">
      {["all", "draft", "sent", "paid"].map((s) => (
        <button
          key={s}
          onClick={() => setStatus(s)}
          className={params.get("status") === s ? "font-bold" : ""}
        >
          {s}
        </button>
      ))}
    </div>
  );
}

La regla que sigo: cada componente es un Server Component hasta que necesita useState, useEffect, manejadores de eventos o APIs del navegador. Entonces, y solo entonces, agrega "use client", e intenta hacer ese componente lo mas pequeño posible.

El patrón de children es tu mejor aliado aquí. Un Client Component puede recibir Server Components como children:

typescript
"use client";
 
export function InteractiveWrapper({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
      {isOpen && children}  {/* children pueden ser Server Components */}
    </div>
  );
}

Así es como mantienes el 90% de tu arbol de componentes en el servidor mientras sigues teniendo interactividad donde importa.

Barrel Files: El Asesino Silencioso del Build#

Solia poner un index.ts en cada carpeta. Imports limpios, no?

typescript
// features/invoicing/components/index.ts
export { InvoiceTable } from "./invoice-table";
export { InvoiceRow } from "./invoice-row";
export { InvoiceForm } from "./invoice-form";
export { InvoicePdf } from "./invoice-pdf";

Luego podia importar así:

typescript
import { InvoiceTable } from "@/features/invoicing/components";

Se ve bonito. Aquí esta el problema: cuando el bundler ve ese import, tiene que evaluar el barrel file completo para descifrar que es InvoiceTable. Eso significa que carga InvoiceRow, InvoiceForm e InvoicePdf también, incluso si solo necesitabas la tabla.

En un proyecto, eliminar los barrel files internos redujo el conteo de modulos de 11,000 a 3,500 durante el desarrollo. El servidor de desarrollo paso de 5-10 segundos de carga por página a menos de 2 segundos. El JavaScript de la primera carga bajo de mas de un megabyte a alrededor de 200 KB.

Vercel construyo optimizePackageImports en next.config.ts para manejar esto con paquetes de terceros de node_modules. Pero no arregla tus propios barrel files. Para tu propio código, la respuesta es mas simple: no los uses.

typescript
// Haz esto
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
 
// No esto
import { InvoiceTable } from "@/features/invoicing/components";

Si, la ruta del import es mas larga. Tu build es mas rápido, tus bundles son mas pequenos y tu servidor de desarrollo no se ahoga. Ese es un intercambio que acepto siempre.

La única excepcion: la API pública de una feature. Si features/invoicing/ expone un único index.ts a nivel raiz de la feature con solo las 2-3 cosas que otras features pueden importar, eso esta bien. Actua como un limite, no un atajo de conveniencia. Pero barrel files dentro de subcarpetas como components/index.ts o hooks/index.ts? Eliminalos.

Páginas Delgadas, Features Robustas#

La regla arquitectonica mas importante que sigo es esta: los archivos de página son cableado, no lógica.

Un archivo de página en app/ debería hacer tres cosas:

  1. Obtener datos (si es un Server Component)
  2. Importar componentes de features
  3. Componerlos juntos

Eso es todo. Sin lógica de negocio. Sin renderizado condicional complejo. Sin arboles JSX de 200 líneas. Si un archivo de página crece mas alla de 40 líneas, estas poniendo código de feature en el lugar equivocado.

typescript
// app/(dashboard)/invoices/[id]/page.tsx
import { notFound } from "next/navigation";
import { db } from "@/shared/lib/db";
import { InvoiceDetail } from "@/features/invoicing/components/invoice-detail";
 
interface Props {
  params: Promise<{ id: string }>;
}
 
export default async function InvoicePage({ params }: Props) {
  const { id } = await params;
  const invoice = await db.query.invoices.findFirst({
    where: (inv, { eq }) => eq(inv.id, id),
  });
 
  if (!invoice) notFound();
 
  return <InvoiceDetail invoice={invoice} />;
}

Doce líneas. La página obtiene los datos que necesita, maneja el caso de no encontrado y delega todo lo demas a la feature. Si la UI del detalle de factura cambia, editas features/invoicing/, no app/. Si la ruta cambia, mueves el archivo de página y al código de la feature no le importa.

Así es como obtienes una base de código donde 15 desarrolladores pueden trabajar simultaneamente sin pisarse unos a otros.

La Verdad Incomoda#

No existe una estructura de carpetas que prevenga el código malo. He visto proyectos bellamente organizados llenos de abstracciones terribles, y estructuras planas desordenadas que eran fáciles de trabajar porque el código en si era claro.

La estructura es una herramienta, no un objetivo. El enfoque basado en features que he descrito aquí funciona porque optimiza para lo que mas importa a escala: puede un desarrollador que nunca ha visto esta base de código encontrar lo que necesita y cambiarlo sin romper algo mas?

Si tu estructura actual responde "si" a esa pregunta, no la refactorices porque un artículo de blog te dijo que lo hicieras, ni siquiera este.

Pero si tus desarrolladores pasan mas tiempo navegando la base de código que escribiendo código, si "donde va esto?" es una pregunta recurrente en los PRs, si un cambio en facturacion de alguna manera rompe el dashboard, entonces tu estructura esta luchando contra ti. Y los patrones de arriba son como he arreglado eso, cada vez.

Artículos relacionados