Illustration gestapelter Schichten als Sinnbild für Feature-Sliced Design

Feature-Sliced Design in einer Next.js-App-Router-Codebase

Der App Router gibt dir Routing, keine Architektur. Eine pragmatische Abbildung der FSD-Schichten auf app/, Server-Komponenten und geteilte UI — inklusive der Teile, die man weglassen sollte.

Der App Router beantwortet, wo eine URL rendert. Er hat keine Meinung dazu, wo ein Feature lebt — und genau diese Frage entscheidet, ob eine Codebase nach einem Jahr noch angenehm ist.

Das Standardergebnis ist bekannt: components/ wächst auf zweihundert Dateien, die Hälfte einmal verwendet, plus eine utils.ts, die alles importiert und niemand sicher ändern kann. Feature-Sliced Design ist eine Antwort darauf — eine Schichtenkonvention mit einer expliziten Regel, in welche Richtung Importe zeigen dürfen. Diese Seite ist damit gebaut, die Beispiele unten sind also die echte Struktur und kein Diagramm.

Die Schichten und die eine Regel, die zählt

FSD staffelt eine Codebase in Schichten und erlaubt Importe nur in eine Richtung: nach unten. Ein Widget darf ein Feature importieren; ein Feature darf niemals ein Widget importieren.

txt
src/
├── app/        # Routen, Layouts, Metadaten — dieser Ordner gehoert Next.js
├── widgets/    # eigenstaendige Seitenabschnitte (Nav, Hero, Artikel)
├── features/   # nutzerseitige Faehigkeiten mit State (Chat, Suche)
├── entities/   # Domaenenobjekte und ihre Inhalte (Post, User)
└── shared/     # UI-Kit, API-Client, i18n, Config, Hooks

Diese eine Einschränkung erzeugt die nützlichen Eigenschaften. Ein Feature zu löschen kann kein Widget brechen, das es nie gekannt hat. Dass shared nichts importiert, hält es wirklich wiederverwendbar, statt still zur zweiten Heimat für Geschäftslogik zu werden. Und ein Import in die falsche Richtung ist ein mechanischer, prüfbarer Fehler statt Geschmackssache.

Eine Namenskollision ist zu beachten: Kanonisches FSD nennt seine oberste Schicht app und meint damit Anwendungs-Setup — Provider, globale Styles. Next.js nennt seinen Routing-Ordner ebenfalls app. In der Praxis gewinnt die Next.js-Bedeutung, weil das Framework den Namen verlangt; das Anwendungs-Setup lebt im Root-Layout und in shared/config. Kein Kampf, der sich lohnt.

Route-Dateien dünn halten

Eine page.tsx sollte wie ein Inhaltsverzeichnis lesen: Params auflösen, Locale auflösen, Metadaten exportieren, ein Widget rendern. Sobald eine Route-Datei Layout-Entscheidungen enthält, lässt sich dieser Screen nirgends mehr wiederverwenden — und in einer lokalisierten Seite kommt "nirgends" sofort, weil derselbe Screen unter zwei URL-Präfixen existiert.

tsx
// app/blog/[slug]/page.tsx
export const dynamicParams = false;

export const generateStaticParams = async () =>
  getPosts().map((post) => ({ slug: post.slug }));

export default async function BlogPostPage({ params }: BlogPostPageProps) {
  const { slug } = await params;
  const post = getPost(slug);

  if (!post) notFound();

  return (
    <main className="main">
      <PostArticle post={post} locale={DEFAULT_LOCALE} />
    </main>
  );
}

Die deutsche Route sind dann dieselben sechs Zeilen mit einer anderen Locale-Konstante. Der Screen selbst — Kopf, Meta-Spalte, Text, ähnliche Beiträge — hat eine Implementierung, und Übersetzung ist ein Parameter statt einer Abzweigung. Zwei dünne Route-Dateien zu pflegen ist deutlich günstiger als eine clevere gemeinsame.

Server-Komponenten verschieben die Grenze, nicht die Struktur

Das Nützlichste, was FSD in einem App-Router-Projekt tut, ist die Client-Grenze sichtbar zu machen. Interaktivität ist eine Eigenschaft eines konkreten UI-Teils, und Slices geben diesem Teil einen offensichtlichen Ort.

  • `entities` bleiben reine Daten und Typen. Kein React, kein use client, kein Fetching. Diese Schicht soll aus einem Route Handler, einem Sitemap-Generator oder einem RSS-Feed importierbar sein, ohne eine Rendering-Runtime mitzuziehen.
  • `widgets` sind standardmäßig Server-Komponenten. Ein Blog-Artikel rendert als statisches HTML — genau das, was Suchmaschinen und langsame Verbindungen gleichermaßen wollen.
  • `features` sind der Ort für State und damit meist der Ort für use client — aber am Blatt, nicht am Wrapper.
  • `shared/ui` bleibt präsentational. Eine geteilte Komponente, die in einen Store greift, ist nicht mehr geteilt; sie hat es nur noch nicht bemerkt.

Ein konkretes Beispiel für die Blatt-Regel: Diese Artikelseite hat genau ein interaktives Element, einen Button zum Kopieren des Links. Den ganzen Artikel dafür als use client zu markieren würde den kompletten Text-Renderer für einen navigator.clipboard-Aufruf in den Browser ausliefern. Als eigene Client-Komponente in einem serverseitig gerenderten Widget kostet es ein paar hundert Byte. Das ist der Mechanismus hinter dem meisten in Performance-Budgets, die eine echte Roadmap überleben.

Jeder Slice hat eine öffentliche API

Jeder Slice exponiert eine index.ts, alles andere ist intern. Das ist der Teil, den Teams gern überspringen — und der Teil, der am meisten leistet.

ts
// entities/post/index.ts
export * from './model/types';
export {
  getPosts,
  getPost,
  getRelatedPosts,
  getReadingMinutes,
  formatPostDate,
  getPostPath,
} from './model/selectors';

Daraus folgen zwei Dinge. Refactoring innerhalb eines Slices ist keine querschnittliche Änderung mehr — Dateien verschieben, Funktionen umbenennen, Module aufteilen, und kein Konsument merkt es. Und die Exportliste wird selbst zum Design-Review: Ein Slice, dessen index.ts dreißig Symbole exportiert, sagt dir, dass es eigentlich zwei Slices sind.

Die Importrichtung lässt sich mit eslint-plugin-boundaries oder einer Pfad-Restriktionsregel durchsetzen. In einer kleinen Codebase genügen eine dokumentierte Konvention und Code-Review. Bei mehr als einer Handvoll Mitwirkender sollte der Linter es tun — Architekturregeln, die davon abhängen, dass sich alle daran erinnern, verfallen genau so schnell, wie man es erwarten würde.

Was man weglassen sollte

Wörtlich angewendet erzeugt FSD viele Ordner. Die behaltenswerte Variante ist kleiner als die Spezifikation.

  • Nicht jedes Segment vorab anlegen. ui, model, lib, api, config pro Slice ist eine Vorlage, keine Pflicht. Ein Segment kommt dazu, wenn es eine zweite Datei hat.
  • Keine `entity` für etwas ohne Verhalten. Ein Typ in shared ist in Ordnung. Entities verdienen ihren Platz, wenn sie Selektoren, Invarianten oder eigene Inhalte haben.
  • Kein `processes`. Die Schicht existiert in älteren Fassungen der Spezifikation und wird nach meiner Erfahrung zum Ordner für alles, wofür niemand entscheiden will.
  • Eine Fünf-Seiten-Website nicht slicen. Struktur hat Fixkosten. Unter einer bestimmten Größe sind app plus shared plus eine Handvoll Komponenten die richtige Architektur, und Schichten hinzuzufügen ist nur Zeremonie.

FSD ist nicht die einzige funktionierende Antwort und nicht der interessante Teil eines Produkts. Was es leistet, ist eine wiederkehrende Diskussion darüber zu beenden, wo Code hingehört — und das ist überraschend viel wert, wenn eine Codebase den Kontext überlebt, der sie erzeugt hat. Der Quellcode dieser Seite ist öffentlich, falls du die Schichtung an etwas Echtem sehen willst, inklusive des Blogs, den du gerade liest.

Feature-Sliced Design in einer Next.js-App-Router-Codebase — Vladyslav Dobrodii