Next.js-Projecten Structureren op Schaal: Wat Ik Op de Harde Manier Leerde
Zuurverdiende lessen over het organiseren van Next.js-codebases die niet bezwijken onder hun eigen gewicht. Feature-gebaseerde architectuur, route groups, server/client-grenzen, barrel file-valkuilen en een echte mappenstructuur die je kunt overnemen.
Elk Next.js-project begint netjes. Een paar pagina's, een components-map, misschien een lib/utils.ts. Je voelt je productief. Je voelt je georganiseerd.
Dan gaan er zes maanden voorbij. Drie developers komen erbij. De components-map heeft 140 bestanden. Iemand heeft utils2.ts aangemaakt omdat utils.ts 800 regels was. Niemand weet welke layout welke route omhult. Je build duurt vier minuten en je weet niet precies waarom.
Ik heb deze cyclus meerdere keren meegemaakt. Wat volgt is wat ik nu daadwerkelijk doe, nadat ik de meeste van deze lessen heb geleerd door eerst het verkeerde te shippen.
De Standaardstructuur Valt Snel Uit Elkaar#
De Next.js-documentatie suggereert een projectstructuur. Die is prima. Het werkt voor een persoonlijke blog of een klein hulpmiddel. Maar zodra je meer dan twee developers aan dezelfde codebase hebt werken, begint de "type-gebaseerde" aanpak te scheuren:
src/
components/
Button.tsx
Modal.tsx
UserCard.tsx
DashboardSidebar.tsx
InvoiceTable.tsx
InvoiceRow.tsx
InvoiceActions.tsx
...nog 137 bestanden
hooks/
useAuth.ts
useInvoice.ts
useDebounce.ts
utils/
format.ts
validate.ts
misc.ts <- het begin van het einde
app/
page.tsx
dashboard/
page.tsx
Het probleem is niet dat dit "fout" is. Het probleem is dat het lineair schaalt met features maar niet met begrijpelijkheid. Wanneer een nieuwe developer aan facturen moet werken, moet die tegelijkertijd components/, hooks/, utils/, types/ en app/ openen. De facturatie-feature is verspreid over vijf mappen. Er is geen enkele plek die zegt "dit is hoe facturatie eruitziet."
Ik zag een team van vier developers een volledige sprint besteden aan het refactoren naar precies deze structuur, overtuigd dat het de "schone" manier was. Binnen drie maanden waren ze weer in verwarring, alleen met meer mappen.
Feature-Gebaseerde Colocatie Wint. Elke Keer.#
Het patroon dat daadwerkelijk het contact met een echt team overleefde is feature-gebaseerde colocatie. Het idee is simpel: alles wat met een feature te maken heeft, leeft bij elkaar.
Niet "alle componenten in een map en alle hooks in een andere." Het tegenovergestelde. Alle componenten, hooks, types en utilities voor facturatie leven in features/invoicing/. Alles voor authenticatie leeft in 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 };
}Let op de imports. Ze zijn allemaal relatief, allemaal binnen de feature. Deze hook reikt niet naar een globale hooks/-map of een gedeelde utils/format.ts. Het gebruikt de eigen opmaaklogica van de facturatie-feature, omdat de opmaak van factuurnummers een facturatiekwestie is, niet een globale.
Het contra-intuitieve deel: dit betekent dat je wat duplicatie zult hebben. Je facturatie-feature heeft misschien een format.ts en je billing-feature heeft misschien ook een format.ts. Dat is prima. Dat is eigenlijk beter dan een gedeelde formatters.ts waar 14 features van afhankelijk zijn en die niemand veilig kan wijzigen.
Ik heb me hier lang tegen verzet. DRY was heilig. Maar ik heb gezien dat gedeelde utility-bestanden de gevaarlijkste bestanden in een codebase worden, bestanden waar een "kleine fix" voor een feature drie andere breekt. Colocatie met gecontroleerde duplicatie is beter onderhoudbaar dan geforceerd delen.
De Mappenstructuur Die Ik Daadwerkelijk Gebruik#
Hier is de volledige boom. Dit is beproefd in twee productieapplicaties met teams van 4-8 personen:
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
Een paar dingen om op te letten:
app/bevat alleen routeringslogica. Paginabestanden zijn dun. Ze importeren uitfeatures/en componeren. Een paginabestand moet maximaal 20-40 regels zijn.features/is waar de echte code leeft. Elke feature is op zichzelf staand. Je kuntfeatures/invoicing/verwijderen en niets anders breekt (behalve de pagina's die eruit importeren, en dat is precies de koppeling die je wilt).shared/is geen stortplaats. Het heeft strikte subcategorieen. Hieronder meer.
Route Groups: Meer Dan Alleen Organisatie#
Route groups, de mappen (tussen haakjes), zijn een van de meest onderbenutte features in de App Router. Ze beinvloeden de URL helemaal niet. (dashboard)/invoices/page.tsx rendert op /invoices, niet /dashboard/invoices.
Maar de echte kracht is layout-isolatie. Elke route group krijgt zijn eigen 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 />
</>
);
}De authenticatiecontrole vindt een keer plaats op layout-niveau, niet op elke pagina. Publieke pagina's krijgen een compleet andere shell. Authenticatiepagina's krijgen helemaal geen layout, alleen een gecentreerde kaart. Dit is schoon, expliciet en moeilijk om te verprutsen.
Ik heb teams gezien die authenticatiecontroles in middleware plaatsten, in individuele pagina's, in componenten, allemaal tegelijk. Kies een plek. Voor de meeste apps is de route group layout die plek.
De shared/-Map Is Niet utils/#
De snelste manier om een onderhoudsnachtmerrie te creeren is een lib/utils.ts-bestand. Het begint klein. Dan wordt het de rommellade van de codebase, waar elke functie die geen duidelijke thuisplek heeft, belandt.
Hier is mijn regel: de shared/-map vereist rechtvaardiging. Iets gaat alleen in shared/ als:
- Het door 3+ features wordt gebruikt, en
- Het oprecht generiek is (niet bedrijfslogica vermomd als generiek)
Een datumopmaakfunctie die factuurvervaldata opmaakt? Die hoort in features/invoicing/utils/. Een datumopmaakfunctie die een Date neemt en een locale string retourneert? Die kan in shared/.
De shared/lib/-submap is specifiek voor integraties met derden en infrastructuur: database-clients, authenticatiebibliotheken, betalingsproviders. Dit zijn dingen waar de hele applicatie van afhankelijk is, maar die geen enkele feature bezit.
// shared/lib/db.ts — dit hoort in 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 — dit hoort NIET in 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}`;
}Als je naar shared/utils/ grijpt en het bestand niet specifieker kunt noemen dan helpers.ts, stop. Die code hoort waarschijnlijk in een feature.
Server vs. Client Components: Trek de Lijn Bewust#
Hier is een patroon dat ik constant zie in codebases met prestatieproblemen:
// Dit ziet er redelijk uit maar is een fout
"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>
);
}De pagina is gemarkeerd als "use client". Nu zit alles wat het importeert, InvoiceTable, InvoiceFilters, de hook, elke dependency van elke dependency, in de client-bundle. Je tabel met 200 rijen die op de server gerenderd had kunnen worden met nul JavaScript wordt nu naar de browser gestuurd.
De oplossing is om "use client" naar beneden te duwen, naar de leaf-componenten die het daadwerkelijk nodig hebben:
// app/(dashboard)/invoices/page.tsx — Server Component (geen directive)
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 — heeft interactieve filters */}
<InvoiceTable data={invoices} /> {/* Server Component — rendert alleen rijen */}
</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>
);
}De regel die ik volg: elk component is een Server Component totdat het useState, useEffect, event handlers of browser-API's nodig heeft. Dan, en alleen dan, voeg je "use client" toe, en probeer je dat component zo klein mogelijk te houden.
Het children-patroon is hier je beste vriend. Een Client Component kan Server Components als children ontvangen:
"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 kunnen Server Components zijn */}
</div>
);
}Dit is hoe je 90% van je componentenboom op de server houdt terwijl je toch interactiviteit hebt waar het ertoe doet.
Barrel Files: De Stille Build-Killer#
Ik plaatste vroeger een index.ts in elke map. Schone imports, toch?
// 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";Dan kon ik zo importeren:
import { InvoiceTable } from "@/features/invoicing/components";Ziet er netjes uit. Hier is het probleem: wanneer de bundler die import ziet, moet hij het volledige barrel-bestand evalueren om te achterhalen wat InvoiceTable is. Dat betekent dat het ook InvoiceRow, InvoiceForm en InvoicePdf laadt, zelfs als je alleen de tabel nodig had.
In een project verminderde het verwijderen van interne barrel files het aantal modules van 11.000 naar 3.500 tijdens development. De dev-server ging van 5-10 seconden laadtijd per pagina naar minder dan 2 seconden. First Load JavaScript daalde van meer dan een megabyte naar ongeveer 200 KB.
Vercel heeft optimizePackageImports gebouwd in next.config.ts om dit af te handelen voor packages van derden uit node_modules. Maar dat fixt niet je eigen barrel files. Voor je eigen code is het antwoord simpeler: gebruik ze niet.
// Doe dit
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
// Niet dit
import { InvoiceTable } from "@/features/invoicing/components";Ja, het importpad is langer. Je build is sneller, je bundles zijn kleiner en je dev-server loopt niet vast. Dat is een ruil die ik elke keer accepteer.
De enige uitzondering: de publieke API van een feature. Als features/invoicing/ een enkele index.ts op feature-rootniveau aanbiedt met slechts de 2-3 dingen die andere features mogen importeren, is dat prima. Het fungeert als een grens, niet als een gemaksafkorting. Maar barrel files in submappen zoals components/index.ts of hooks/index.ts? Verwijder ze.
Dunne Pagina's, Dikke Features#
De belangrijkste architectuurregel die ik volg is deze: paginabestanden zijn bedrading, geen logica.
Een paginabestand in app/ moet drie dingen doen:
- Data ophalen (als het een Server Component is)
- Feature-componenten importeren
- Ze samenvoegen
Dat is het. Geen bedrijfslogica. Geen complexe conditionele rendering. Geen JSX-bomen van 200 regels. Als een paginabestand voorbij 40 regels groeit, zet je featurecode op de verkeerde plek.
// 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} />;
}Twaalf regels. De pagina haalt de data op die het nodig heeft, handelt het not-found-geval af en delegeert al het andere naar de feature. Als de factuurdetail-UI verandert, bewerk je features/invoicing/, niet app/. Als de route verandert, verplaats je het paginabestand en de featurecode heeft er geen last van.
Dit is hoe je een codebase krijgt waar 15 developers tegelijkertijd kunnen werken zonder op elkaars tenen te trappen.
De Ongemakkelijke Waarheid#
Er is geen mappenstructuur die slechte code voorkomt. Ik heb prachtig georganiseerde projecten gezien vol verschrikkelijke abstracties, en rommelige platte structuren die prettig werkten omdat de code zelf duidelijk was.
Structuur is een hulpmiddel, geen doel. De feature-gebaseerde aanpak die ik hier heb beschreven werkt omdat het optimaliseert voor het ding dat er op schaal het meest toe doet: kan een developer die deze codebase nog nooit heeft gezien, vinden wat hij nodig heeft en het wijzigen zonder iets anders te breken?
Als je huidige structuur "ja" op die vraag antwoordt, refactor dan niet omdat een blogpost je dat vertelde, zelfs deze niet.
Maar als je developers meer tijd besteden aan het navigeren door de codebase dan aan het schrijven van code, als "waar hoort dit?" een terugkerende vraag is in PR's, als een wijziging aan billing op de een of andere manier het dashboard breekt, dan werkt je structuur tegen je. En de patronen hierboven zijn hoe ik dat heb opgelost, elke keer.