Next.js-Projekte im großen Maßstab strukturieren: Was ich auf die harte Tour gelernt habe
Hart erarbeitete Lektionen zur Organisation von Next.js-Codebasen, die nicht unter ihrem eigenen Gewicht zusammenbrechen. Feature-basierte Architektur, Route Groups, Server/Client-Grenzen, Barrel-File-Fallen und eine echte Ordnerstruktur zum Mitnehmen.
Jedes Next.js-Projekt beginnt sauber. Ein paar Seiten, ein Components-Ordner, vielleicht eine lib/utils.ts. Man fühlt sich produktiv. Man fühlt sich organisiert.
Dann vergehen sechs Monate. Drei Entwickler kommen dazu. Der Components-Ordner hat 140 Dateien. Jemand hat utils2.ts erstellt, weil utils.ts 800 Zeilen lang war. Niemand weiß mehr, welches Layout welche Route umschließt. Dein Build dauert vier Minuten und du weißt nicht genau warum.
Ich habe diesen Zyklus mehrfach durchlebt. Was folgt, ist das, was ich jetzt tatsächlich mache, nachdem ich die meisten dieser Lektionen gelernt habe, indem ich zuerst das Falsche ausgeliefert habe.
Die Standardstruktur zerfällt schnell#
Die Next.js-Dokumentation schlägt eine Projektstruktur vor. Sie ist okay. Sie funktioniert für einen persönlichen Blog oder ein kleines Tool. Aber sobald mehr als zwei Entwickler an derselben Codebasis arbeiten, beginnt der "typbasierte" Ansatz zu bröckeln:
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 ← der Anfang vom Ende
app/
page.tsx
dashboard/
page.tsx
Das Problem ist nicht, dass das "falsch" ist. Es ist, dass es linear mit Features skaliert, aber nicht mit dem Verständnis. Wenn ein neuer Entwickler an Rechnungen arbeiten muss, muss er components/, hooks/, utils/, types/ und app/ gleichzeitig öffnen. Das Rechnungs-Feature ist über fünf Verzeichnisse verstreut. Es gibt keinen einzelnen Ort, der sagt: "So sieht die Rechnungsstellung aus."
Ich habe zugesehen, wie ein Team von vier Entwicklern einen ganzen Sprint damit verbracht hat, genau in diese Struktur umzubauen, überzeugt, dass es der "saubere" Weg sei. Innerhalb von drei Monaten waren sie wieder genauso verwirrt, nur mit mehr Ordnern.
Feature-basierte Colocation gewinnt. Jedes Mal.#
Das Muster, das den Kontakt mit einem echten Team tatsächlich überlebt hat, ist feature-basierte Colocation. Die Idee ist simpel: Alles, was zu einem Feature gehört, lebt zusammen.
Nicht "alle Komponenten in einem Ordner und alle Hooks in einem anderen." Das Gegenteil. Alle Komponenten, Hooks, Typen und Utilities für die Rechnungsstellung leben in features/invoicing/. Alles für Auth lebt 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 };
}Beachte die Imports. Sie sind alle relativ, alle innerhalb des Features. Dieser Hook greift nicht in einen globalen hooks/-Ordner oder eine gemeinsame utils/format.ts. Er verwendet die eigene Formatierungslogik des Rechnungs-Features, weil die Formatierung von Rechnungsnummern eine Angelegenheit der Rechnungsstellung ist, keine globale.
Der kontraintuitive Teil: Das bedeutet, dass du etwas Duplizierung haben wirst. Dein Rechnungs-Feature hat vielleicht eine format.ts und dein Abrechnungs-Feature vielleicht auch eine format.ts. Das ist in Ordnung. Das ist sogar besser als eine gemeinsame formatters.ts, von der 14 Features abhängen und die niemand sicher ändern kann.
Ich habe mich lange dagegen gewehrt. DRY war Evangelium. Aber ich habe gesehen, wie gemeinsame Utility-Dateien zu den gefährlichsten Dateien einer Codebasis werden, Dateien, bei denen ein "kleiner Fix" für ein Feature drei andere kaputt macht. Colocation mit kontrollierter Duplizierung ist wartbarer als erzwungenes Teilen.
Die Ordnerstruktur, die ich tatsächlich verwende#
Hier ist der vollständige Baum. Er wurde in zwei Produktionsanwendungen mit 4-8 Personen starken Teams kampferprobt:
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
Ein paar Dinge, die auffallen:
app/enthält nur Routing-Logik. Page-Dateien sind dünn. Sie importieren ausfeatures/und komponieren. Eine Page-Datei sollte maximal 20-40 Zeilen haben.features/ist, wo der echte Code lebt. Jedes Feature ist eigenständig. Du kannstfeatures/invoicing/löschen und nichts anderes geht kaputt (außer den Seiten, die daraus importieren, was genau die Kopplung ist, die du willst).shared/ist keine Abladestelle. Es hat strenge Unterkategorien. Dazu gleich mehr.
Route Groups: Mehr als nur Organisation#
Route Groups, die (eingeklammerten) Ordner, sind eines der am meisten unterschätzten Features im App Router. Sie beeinflussen die URL überhaupt nicht. (dashboard)/invoices/page.tsx rendert unter /invoices, nicht unter /dashboard/invoices.
Aber die wahre Stärke ist die Layout-Isolation. Jede Route Group bekommt ihr eigenes 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 />
</>
);
}Die Auth-Prüfung findet einmal auf Layout-Ebene statt, nicht auf jeder Seite. Öffentliche Seiten bekommen eine komplett andere Hülle. Auth-Seiten bekommen gar kein Layout, nur eine zentrierte Karte. Das ist sauber, explizit und schwer zu vermasseln.
Ich habe Teams gesehen, die Auth-Prüfungen in Middleware, in einzelnen Seiten und in Komponenten gleichzeitig eingebaut haben. Entscheide dich für einen Ort. Für die meisten Apps ist das Route-Group-Layout dieser Ort.
Der shared/-Ordner ist nicht utils/#
Der schnellste Weg, einen Wartungsalptraum zu schaffen, ist eine lib/utils.ts-Datei. Sie fängt klein an. Dann wird sie zur Schublade der Codebasis, in der jede Funktion landet, die kein offensichtliches Zuhause hat.
Hier ist meine Regel: Der shared/-Ordner erfordert eine Begründung. Etwas kommt nur dann in shared/, wenn:
- Es von 3+ Features verwendet wird, und
- Es wirklich generisch ist (nicht Business-Logik in generischer Verkleidung)
Eine Datumsformatierungsfunktion, die Fälligkeitsdaten von Rechnungen formatiert? Die gehört nach features/invoicing/utils/. Eine Datumsformatierungsfunktion, die ein Date nimmt und einen Locale-String zurückgibt? Die kann nach shared/.
Der shared/lib/-Unterordner ist speziell für Drittanbieter-Integrationen und Infrastruktur: Datenbank-Clients, Auth-Bibliotheken, Zahlungsanbieter. Das sind Dinge, von denen die gesamte Anwendung abhängt, die aber kein einzelnes Feature besitzt.
// shared/lib/db.ts — gehört 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 — gehört NICHT 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}`;
}Wenn du nach shared/utils/ greifst und die Datei nicht spezifischer als helpers.ts benennen kannst, halt inne. Dieser Code gehört wahrscheinlich in ein Feature.
Server vs. Client Components: Ziehe die Grenze bewusst#
Hier ist ein Muster, das ich ständig in Codebasen mit Performance-Problemen sehe:
// Das sieht vernünftig aus, ist aber ein Fehler
"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>
);
}Die Seite ist mit "use client" markiert. Jetzt ist alles, was sie importiert -- InvoiceTable, InvoiceFilters, der Hook, jede Abhängigkeit jeder Abhängigkeit -- im Client-Bundle. Deine 200-Zeilen-Tabelle, die auf dem Server ohne JavaScript hätte gerendert werden können, wird jetzt an den Browser ausgeliefert.
Die Lösung ist, "use client" nach unten zu den Blatt-Komponenten zu schieben, die es tatsächlich brauchen:
// app/(dashboard)/invoices/page.tsx — Server Component (keine Direktive)
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 — hat interaktive Filter */}
<InvoiceTable data={invoices} /> {/* Server Component — rendert nur Zeilen */}
</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>
);
}Die Regel, der ich folge: Jede Komponente ist eine Server Component, bis sie useState, useEffect, Event-Handler oder Browser-APIs braucht. Erst dann, und nur dann, füge "use client" hinzu und versuche, diese Komponente so klein wie möglich zu machen.
Das Children-Pattern ist hier dein bester Freund. Eine Client Component kann Server Components als children empfangen:
"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 können Server Components sein */}
</div>
);
}So behältst du 90% deines Komponentenbaums auf dem Server und hast trotzdem Interaktivität dort, wo sie wichtig ist.
Barrel Files: Der stille Build-Killer#
Ich habe früher in jeden Ordner eine index.ts gelegt. Saubere Imports, oder?
// 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";Dann konnte ich so importieren:
import { InvoiceTable } from "@/features/invoicing/components";Sieht schön aus. Hier ist das Problem: Wenn der Bundler diesen Import sieht, muss er die gesamte Barrel-Datei auswerten, um herauszufinden, was InvoiceTable ist. Das bedeutet, er lädt auch InvoiceRow, InvoiceForm und InvoicePdf, selbst wenn du nur die Tabelle brauchst.
In einem Projekt hat das Entfernen interner Barrel Files die Modulanzahl während der Entwicklung von 11.000 auf 3.500 reduziert. Der Dev-Server ging von 5-10 Sekunden Ladezeit pro Seite auf unter 2 Sekunden. Das First-Load-JavaScript sank von über einem Megabyte auf rund 200 KB.
Vercel hat optimizePackageImports in next.config.ts entwickelt, um dies für Drittanbieter-Pakete aus node_modules zu lösen. Aber es behebt nicht deine eigenen Barrel Files. Für deinen eigenen Code ist die Antwort einfacher: Verwende sie nicht.
// Das hier
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
// Nicht das
import { InvoiceTable } from "@/features/invoicing/components";Ja, der Import-Pfad ist länger. Dein Build ist schneller, deine Bundles sind kleiner und dein Dev-Server geht nicht in die Knie. Diesen Tausch nehme ich jedes Mal.
Die eine Ausnahme: die öffentliche API eines Features. Wenn features/invoicing/ eine einzelne index.ts auf der Feature-Stammebene mit nur den 2-3 Dingen exportiert, die andere Features importieren dürfen, ist das in Ordnung. Sie dient als Grenze, nicht als Komfort-Abkürzung. Aber Barrel Files innerhalb von Unterordnern wie components/index.ts oder hooks/index.ts? Weg damit.
Dünne Pages, fette Features#
Die wichtigste architektonische Regel, der ich folge, ist: Page-Dateien sind Verdrahtung, nicht Logik.
Eine Page-Datei in app/ sollte drei Dinge tun:
- Daten abrufen (wenn es eine Server Component ist)
- Feature-Komponenten importieren
- Sie zusammensetzen
Das war's. Keine Business-Logik. Kein komplexes bedingtes Rendering. Keine 200-Zeilen-JSX-Bäume. Wenn eine Page-Datei über 40 Zeilen hinauswächst, schreibst du Feature-Code an der falschen Stelle.
// 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} />;
}Zwölf Zeilen. Die Seite holt die benötigten Daten, behandelt den Not-Found-Fall und delegiert alles andere an das Feature. Wenn sich die Rechnungsdetail-UI ändert, bearbeitest du features/invoicing/, nicht app/. Wenn sich die Route ändert, verschiebst du die Page-Datei, und der Feature-Code kümmert sich nicht darum.
So bekommt man eine Codebasis, in der 15 Entwickler gleichzeitig arbeiten können, ohne sich gegenseitig in die Quere zu kommen.
Die unbequeme Wahrheit#
Es gibt keine Ordnerstruktur, die schlechten Code verhindert. Ich habe wunderschön organisierte Projekte voller schrecklicher Abstraktionen gesehen, und chaotische flache Strukturen, in denen es sich leicht arbeiten ließ, weil der Code selbst klar war.
Struktur ist ein Werkzeug, kein Ziel. Der feature-basierte Ansatz, den ich hier beschrieben habe, funktioniert, weil er für das optimiert, was im großen Maßstab am meisten zählt: Kann ein Entwickler, der diese Codebasis noch nie gesehen hat, finden, was er braucht, und es ändern, ohne etwas anderes kaputt zu machen?
Wenn deine aktuelle Struktur diese Frage mit "ja" beantwortet, baue sie nicht um, weil ein Blogbeitrag es dir gesagt hat -- auch nicht dieser hier.
Aber wenn deine Entwickler mehr Zeit damit verbringen, durch die Codebasis zu navigieren als Code zu schreiben, wenn "wo gehört das hin?" eine wiederkehrende Frage in PRs ist, wenn eine Änderung an der Abrechnung irgendwie das Dashboard kaputt macht, dann kämpft deine Struktur gegen dich. Und die oben beschriebenen Muster sind, wie ich das jedes Mal behoben habe.