Strutturare progetti Next.js su larga scala: cosa ho imparato a mie spese
Lezioni conquistate sul campo per organizzare codebase Next.js che non crollano sotto il proprio peso. Architettura basata su feature, route group, confini server/client, trappole dei barrel file e una struttura di cartelle reale.
Ogni progetto Next.js parte pulito. Qualche pagina, una cartella components, magari un lib/utils.ts. Ti senti produttivo. Ti senti organizzato.
Poi passano sei mesi. Tre sviluppatori si uniscono al team. La cartella components ha 140 file. Qualcuno ha creato utils2.ts perché utils.ts era arrivato a 800 righe. Nessuno sa quale layout avvolge quale route. Il build richiede quattro minuti e non sai bene perché.
Ho attraversato questo ciclo diverse volte. Quello che segue è ciò che faccio effettivamente adesso, dopo aver imparato la maggior parte di queste lezioni pubblicando prima la cosa sbagliata.
La struttura predefinita crolla in fretta#
La documentazione di Next.js suggerisce una struttura di progetto. Va bene. Funziona per un blog personale o un piccolo strumento. Ma nel momento in cui hai più di due sviluppatori che lavorano sulla stessa codebase, l'approccio "basato sul tipo" inizia a scricchiolare:
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 <- l'inizio della fine
app/
page.tsx
dashboard/
page.tsx
Il problema non è che questo sia "sbagliato". è che scala linearmente con le feature ma non con la comprensione. Quando un nuovo sviluppatore deve lavorare sulle fatture, deve aprire components/, hooks/, utils/, types/ e app/ contemporaneamente. La feature delle fatture è sparsa in cinque directory. Non c'è un singolo posto che dica "ecco com'è fatta la fatturazione".
Ho visto un team di quattro sviluppatori spendere un intero sprint per ristrutturare esattamente in questo modo, convinti che fosse l'approccio "pulito". Entro tre mesi erano di nuovo confusi, solo con più cartelle.
La colocation basata su feature vince. Sempre.#
Il pattern che è effettivamente sopravvissuto al contatto con un team reale è la colocation basata su feature. L'idea è semplice: tutto ciò che è legato a una feature vive insieme.
Non "tutti i componenti in una cartella e tutti gli hook in un'altra". L'opposto. Tutti i componenti, gli hook, i tipi e le utility per la fatturazione vivono dentro features/invoicing/. Tutto ciò che riguarda l'autenticazione vive dentro features/auth/.
// 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 };
}Nota gli import. Sono tutti relativi, tutti all'interno della feature. Questo hook non va a pescare in qualche cartella globale hooks/ o in un utils/format.ts condiviso. Usa la propria logica di formattazione della feature di fatturazione perché la formattazione del numero di fattura è una responsabilità della fatturazione, non globale.
La parte controintuitiva: questo significa che avrai un po' di duplicazione. La tua feature di fatturazione potrebbe avere un format.ts e la tua feature di billing potrebbe averne un altro. Va bene così. In realtà è meglio di un formatters.ts condiviso da cui dipendono 14 feature e che nessuno può modificare in sicurezza.
Ho resistito a lungo su questo punto. Il DRY era un dogma. Ma ho visto file di utility condivise diventare i file più pericolosi di una codebase, file dove un "piccolo fix" per una feature ne rompe altre tre. La colocation con duplicazione controllata è più manutenibile della condivisione forzata.
La struttura di cartelle che uso davvero#
Ecco l'albero completo. Questo è stato testato in battaglia su due applicazioni in produzione con team da 4-8 persone:
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
Alcune cose da notare:
app/contiene solo la logica di routing. I file delle pagine sono leggeri. Importano dafeatures/e compongono. Un file pagina dovrebbe essere di 20-40 righe, massimo.features/è dove vive il codice vero e proprio. Ogni feature è autosufficiente. Puoi cancellarefeatures/invoicing/e nient'altro si rompe (tranne le pagine che importano da essa, che è esattamente l'accoppiamento che vuoi).shared/non è un cestino. Ha sottocategorie precise. Approfondiremo più avanti.
Route Group: più di una semplice organizzazione#
I route group, le cartelle (tra parentesi), sono una delle funzionalità più sottovalutate dell'App Router. Non influenzano affatto l'URL. (dashboard)/invoices/page.tsx renderizza su /invoices, non su /dashboard/invoices.
Ma il vero potere sta nell'isolamento dei layout. Ogni route group ha il proprio layout.tsx:
// 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>
);
}// 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 />
</>
);
}Il controllo dell'autenticazione avviene una sola volta a livello di layout, non in ogni pagina. Le pagine pubbliche hanno un guscio completamente diverso. Le pagine di autenticazione non hanno alcun layout, solo una card centrata. Questo è pulito, esplicito e difficile da sbagliare.
Ho visto team mettere i controlli di autenticazione nel middleware, nelle singole pagine, nei componenti, tutto contemporaneamente. Scegli un posto. Per la maggior parte delle app, il layout del route group è quel posto.
La cartella shared/ non è utils/#
Il modo più veloce per creare un incubo di manutenzione è un file lib/utils.ts. Inizia in piccolo. Poi diventa il cassetto delle cianfrusaglie della codebase, dove finisce ogni funzione che non ha una collocazione ovvia.
Ecco la mia regola: la cartella shared/ richiede una giustificazione. Qualcosa finisce in shared/ solo se:
- Viene usato da 3+ feature, e
- è genuinamente generico (non logica di business travestita da generica)
Una funzione di formattazione date che formatta le scadenze delle fatture? Quella va in features/invoicing/utils/. Una funzione di formattazione date che prende un Date e restituisce una stringa localizzata? Quella può andare in shared/.
La sottocartella shared/lib/ è specificamente per integrazioni con terze parti e infrastruttura: client per database, librerie di autenticazione, provider di pagamento. Sono cose da cui dipende l'intera applicazione ma che nessuna singola feature possiede.
// shared/lib/db.ts -- questo appartiene 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 -- questo NON appartiene 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}`;
}Se stai per usare shared/utils/ e non riesci a dare al file un nome più specifico di helpers.ts, fermati. Quel codice probabilmente appartiene a una feature.
Server vs. Client Component: traccia il confine deliberatamente#
Ecco un pattern che vedo costantemente nelle codebase con problemi di performance:
// Sembra ragionevole ma un errore
"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 pagina è marcata "use client". Ora tutto ciò che importa, InvoiceTable, InvoiceFilters, l'hook, ogni dipendenza di ogni dipendenza, finisce nel bundle client. La tua tabella da 200 righe che avrebbe potuto essere renderizzata sul server con zero JavaScript viene ora spedita al browser.
La soluzione è spingere "use client" verso i componenti foglia che ne hanno effettivamente bisogno:
// app/(dashboard)/invoices/page.tsx -- Server Component (nessuna direttiva)
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 -- ha filtri interattivi */}
<InvoiceTable data={invoices} /> {/* Server Component -- renderizza solo le righe */}
</div>
);
}// 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 regola che seguo: ogni componente è un Server Component finché non ha bisogno di useState, useEffect, event handler o API del browser. Solo allora aggiungi "use client", e cerca di rendere quel componente il più piccolo possibile.
Il pattern dei children è il tuo migliore alleato. Un Client Component può ricevere Server Component come children:
"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 possono essere Server Component */}
</div>
);
}così che mantieni il 90% del tuo albero di componenti sul server pur avendo interattivit dove serve.
Barrel file: il killer silenzioso del build#
Una volta mettevo un index.ts in ogni cartella. Import puliti, giusto?
// 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";Cos potevo importare in questo modo:
import { InvoiceTable } from "@/features/invoicing/components";Sembra elegante. Ecco il problema: quando il bundler vede quell'import, deve valutare l'intero barrel file per capire così' InvoiceTable. Questo significa che carica anche InvoiceRow, InvoiceForm e InvoicePdf, anche se avevi bisogno solo della tabella.
In un progetto, rimuovere i barrel file interni ha ridotto il conteggio dei moduli da 11.000 a 3.500 durante lo sviluppo. Il dev server è passato da 5-10 secondi di caricamento pagina a meno di 2 secondi. Il JavaScript al primo caricamento è sceso da oltre un megabyte a circa 200 KB.
Vercel ha creato optimizePackageImports in next.config.ts per gestire questo con i pacchetti di terze parti da node_modules. Ma non risolve i tuoi barrel file. Per il tuo codice, la risposta è più semplice: non usarli.
// Fai cos
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
// Non cos
import { InvoiceTable } from "@/features/invoicing/components";Sì, il percorso dell'import è più lungo. Il tuo build è più veloce, i bundle sono più piccoli e il dev server non si impalla. Questo è uno scambio che accetto ogni volta.
L'unica eccezione: l'API pubblica di una feature. Se features/invoicing/ espone un singolo index.ts a livello di root della feature con solo le 2-3 cose che altre feature possono importare, va bene. Funziona come un confine, non come una scorciatoia. Ma i barrel file dentro le sottocartelle come components/index.ts o hooks/index.ts? Eliminali.
Pagine leggere, feature sostanziose#
La regola architetturale più importante che seguo è questa: i file delle pagine sono cablaggio, non logica.
Un file pagina in app/ dovrebbe fare tre cose:
- Recuperare i dati (se è un Server Component)
- Importare i componenti della feature
- Comporli insieme
Questo è tutto. Niente logica di business. Niente rendering condizionale complesso. Niente alberi JSX da 200 righe. Se un file pagina supera le 40 righe, stai mettendo il codice della feature nel posto sbagliato.
// 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} />;
}Dodici righe. La pagina recupera i dati necessari, gestisce il caso not-found e delega tutto il resto alla feature. Se l'interfaccia del dettaglio fattura cambia, modifichi features/invoicing/, non app/. Se la route cambia, sposti il file della pagina e il codice della feature non se ne accorge.
così che ottieni una codebase dove 15 sviluppatori possono lavorare simultaneamente senza pestarsi i piedi.
La scomoda verità#
Non esiste una struttura di cartelle che prevenga il codice cattivo. Ho visto progetti splendidamente organizzati pieni di astrazioni terribili, e strutture piatte disordinate in cui era facile lavorare perché il codice in s era chiaro.
La struttura è uno strumento, non un obiettivo. L'approccio basato su feature che ho descritto qui funziona perché ottimizza per la cosa che conta di più su larga scala: uno sviluppatore che non ha mai visto questa codebase riesce a trovare ciò che gli serve e a modificarlo senza rompere qualcos'altro?
Se la tua struttura attuale risponde "s" a questa domanda, non ristrutturarla perché un articolo di blog te l'ha detto, neanche questo.
Ma se i tuoi sviluppatori passano più tempo a navigare la codebase che a scrivere codice, se "dove va questa cosa?" una domanda ricorrente nelle PR, se una modifica al billing in qualche modo rompe la dashboard, allora la tua struttura ti sta remando contro. E i pattern qui sopra sono il modo in cui ho risolto il problema, ogni volta.