Next.js Dynamic Routes: Der komplette Deep Dive für 2026

Next.js Dynamic Routes: Der komplette Deep Dive für 2026

Next.js Dynamic Routes gehören zu den Features, die in der Doku trivial aussehen — und dann still darüber entscheiden, ob deine Seite auf zehntausend Seiten skaliert oder schon bei fünfzig auseinanderfällt. Eckige Klammern im Ordnernamen, fertig, oder? Nicht ganz. Zwischen [slug], [...slug], [[...slug]], generateStaticParams, dynamicParams, ISR und der Async-Params-Änderung in Next.js 15 liegt eine Menge Fläche — und eine Menge Möglichkeiten, versehentlich eine langsame, nicht indexierbare Seite auszuliefern.

Wir haben mehrere Next.js-Seiten in Produktion gebaut und betrieben, von E-Commerce-Storefronts bis zu Content-Plattformen mit tausenden dynamisch gerouteten Seiten. Dieser Artikel ist der Deep Dive, den wir uns am Anfang gewünscht hätten: wie Dynamic Routes im App Router wirklich funktionieren, wie du sie schnell renderst und wie Suchmaschinen sie lieben lernen.

Was Dynamic Routes sind (und warum es sie gibt)

Next.js nutzt dateisystembasiertes Routing. Ein Ordner in app/ wird zu einem URL-Segment, und eine page.tsx darin macht dieses Segment öffentlich erreichbar. app/about/page.tsx ist /about. Simpel.

Aber echte Anwendungen bestehen nicht aus handgeschriebenen Seiten. Ein Blog hat hunderte Beiträge. Ein Shop hat tausende Produkte. Eine Doku-Seite hat verschachtelte Kategorien, die du zur Build-Zeit gar nicht kennst. Du wirst nicht für jede Produkt-ID einen Ordner anlegen — du brauchst ein Seiten-Template, das jedes Produkt rendert. Genau das ist eine Dynamic Route: ein URL-Segment, dessen Wert zur Request-Zeit (oder Build-Zeit) aus der echten URL kommt.

Im App Router erstellst du sie mit eckigen Klammern im Ordnernamen:

app/
└── blog/
    └── [slug]/
        └── page.tsx

Jetzt rendern /blog/hello-world, /blog/nextjs-tipps und /blog/irgendwas-anderes alle durch dieselbe page.tsx. Das Klammer-Segment — hier slug, der Name ist dir überlassen — landet als Param in deiner Komponente.

Next.js App-Verzeichnisbaum mit Klammer-Ordnern, die auf URL-Routen gemappt werden

Die Anatomie einer Dynamic Route in Next.js 15

Hier das kanonische Beispiel — und es enthält bereits die wichtigste Änderung der letzten Next.js-Versionen:

// app/blog/[slug]/page.tsx
interface PageProps {
  params: Promise<{ slug: string }>;
}

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

  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

Beachte params: Promise<{ slug: string }>. Seit Next.js 15 ist params ein Promise und muss awaited werden. Diese Änderung bricht die meisten Tutorials von 2023 und 2024. Früher hast du direkt auf params.slug zugegriffen; jetzt bekommst du ein Promise, damit das Framework mit dem Rendern anfangen kann, bevor der Request-Kontext vollständig aufgelöst ist. Kopierst du alten Code, warnt dich TypeScript — und in neueren Versionen schlägt der synchrone Zugriff schlicht fehl.

Dasselbe gilt für searchParams, die jetzt ebenfalls async sind:

export default async function Page({
  params,
  searchParams,
}: {
  params: Promise<{ slug: string }>;
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
  const { slug } = await params;
  const { sort } = await searchParams;
  // ...
}

Wenn du nur eine Sache aus diesem Abschnitt mitnimmst: In Next.js 15 und neuer immer await params. Das ist aktuell die häufigste Quelle verwirrter Stack-Overflow-Fragen zu Dynamic Routes.

Die drei Varianten dynamischer Segmente

Next.js gibt dir drei Klammer-Syntaxen — und die richtige Wahl ist wichtiger, als viele denken.

1. Einzelnes dynamisches Segment: [slug]

Matcht genau ein Segment. app/products/[id]/page.tsx matcht /products/42, aber nicht /products und nicht /products/42/reviews. Das ist dein Standard für Detailseiten: Blogposts, Produktseiten, Nutzerprofile.

2. Catch-All-Segment: [...slug]

Matcht ein oder mehrere Segmente. app/docs/[...slug]/page.tsx matcht /docs/getting-started, /docs/api/auth und /docs/api/auth/tokens. Der Param kommt als Array an:

// /docs/api/auth/tokens
const { slug } = await params;
// slug = ["api", "auth", "tokens"]

Das ist das Arbeitspferd für Dokumentationsseiten, CMS-getriebene Seitenbäume und alles mit beliebiger Verschachtelungstiefe. Wichtig: Ein normales Catch-All matcht nicht die nackte /docs-Route — dort gäbe es einen 404.

3. Optionales Catch-All: [[...slug]]

Doppelte Klammern machen das Segment optional. app/docs/[[...slug]]/page.tsx matcht /docs und alles darunter. Auf der nackten Route ist der Param undefined, also absichern:

const { slug } = await params;
const path = slug?.join("/") ?? "index";

Nutz das, wenn die Wurzel einer Sektion und ihre Kinder dasselbe Template teilen — etwa eine Doku-Startseite, die einfach nur ein weiteres CMS-Dokument ist.

Diagramm einer Catch-All-Route, die mehrere URL-Segmente einfängt

Routen-Priorität: Wer gewinnt?

Wenn mehrere Routen matchen könnten, löst Next.js in dieser Reihenfolge auf: statisch schlägt dynamisch schlägt Catch-All schlägt optionales Catch-All. Existieren app/blog/featured/page.tsx und app/blog/[slug]/page.tsx gleichzeitig, rendert /blog/featured die statische Variante. Das ist gewollt und praktisch: Du kannst Sonderseiten aus einem dynamischen Template herausschneiden, ohne Conditionals zu schreiben.

Eine Falle: Zwei verschiedene Param-Namen auf derselben Ebene gehen nicht. app/shop/[id] und app/shop/[slug] werfen einen Build-Fehler, weil Next.js nie entscheiden könnte, zu wem /shop/irgendwas gehört.

generateStaticParams: Der Performance-Hebel

Standardmäßig wird eine Dynamic Route on demand gerendert — der Server baut das HTML, wenn der Request reinkommt. Für selten besuchte Seiten okay, aber für einen Blog oder Produktkatalog heißt das: Jeder Besucher (und jeder Crawler) zahlt die Rendering-Kosten.

generateStaticParams dreht das um: Es sagt Next.js zur Build-Zeit, welche Param-Werte existieren, damit diese Seiten als statisches HTML vorgerendert werden.

// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

Zur Build-Zeit ruft Next.js das einmal auf, holt deine Slug-Liste und rendert eine HTML-Datei pro Slug. Requests treffen dann statische Dateien: kein Server-Rendering, keine Datenbank-Query, Antwortzeiten im einstelligen Millisekundenbereich, trivial per CDN cachebar.

Für Catch-All-Routen gibst du die Array-Form zurück:

export async function generateStaticParams() {
  const docs = await getAllDocs();
  return docs.map((doc) => ({
    slug: doc.path.split("/"), // z.B. ["api", "auth"]
  }));
}

Und für verschachtelte Dynamic Routes wie app/[category]/[product]/page.tsx gibst du beide Keys pro Eintrag zurück:

export async function generateStaticParams() {
  const products = await getAllProducts();
  return products.map((p) => ({
    category: p.category,
    product: p.slug,
  }));
}

Vergleich von statischer Generierung zur Build-Zeit und Server-Rendering zur Request-Zeit

dynamicParams: Was passiert mit unbekannten Slugs?

Jetzt der subtile Teil. Was passiert, wenn ein Request für einen Slug reinkommt, den du nicht aus generateStaticParams zurückgegeben hast? Das steuert eine Segment-Config:

export const dynamicParams = true; // Default

Mit true (dem Default) werden unbekannte Slugs on demand gerendert und dann gecacht — effektiv Incremental Static Regeneration. Dein Build bleibt schnell, weil du nur deine Top-100-Seiten vorrendern und den Long Tail sich beim ersten Besuch selbst generieren lassen kannst.

Mit false liefern unbekannte Slugs einen harten 404. Nutz das, wenn dein Seiten-Set geschlossen ist und Müll-URLs (/blog/xyz123) schnell scheitern sollen, statt deine Datenquelle zu belasten.

Ein Muster, das wir in Produktion nutzen: die Seiten vorrendern, die 90 Prozent des Traffics bekommen, dynamicParams anlassen und ISR-Revalidierung dazunehmen:

export const revalidate = 3600; // höchstens einmal pro Stunde neu generieren

export async function generateStaticParams() {
  const topPosts = await getTopPosts(100);
  return topPosts.map((post) => ({ slug: post.slug }));
}

Schnelle Builds, schnelle Seiten, frischer Content. Diese Kombination — partielles Vorrendern des heißen Sets plus On-Demand-ISR für den Rest — ist der Sweet Spot für fast jede Content-Seite.

Noch was: Fetch-Deduplizierung

Innerhalb einer statisch generierten Route werden fetch-Requests mit derselben URL automatisch zwischen generateStaticParams, generateMetadata und deiner Page-Komponente dedupliziert. Nutzt du stattdessen einen Datenbank-Client, wickle deine Daten-Getter in Reacts cache(), damit der Post nicht dreimal pro Seite geladen wird:

import { cache } from "react";

export const getPost = cache(async (slug: string) => {
  return db.post.findUnique({ where: { slug } });
});

Dieser Einzeiler hat uns real Geld bei der Datenbanklast gespart.

Dynamisches SEO: generateMetadata ist nicht optional

Jetzt zu dem Teil, den die meisten Tutorials überspringen — der zufällig darüber entscheidet, ob deine dynamischen Seiten überhaupt ranken. Tausend Produktseiten mit identischen <title>-Tags sind tausend Seiten, die Google als Beinahe-Duplikate einstuft.

Jede Dynamic Route braucht dynamische Metadaten, und der App Router hat dafür eine erstklassige API:

import type { Metadata } from "next";

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug); // dedupliziert via cache()

  if (!post) return { title: "Nicht gefunden" };

  return {
    title: post.title,
    description: post.excerpt,
    alternates: {
      canonical: `https://example.com/blog/${post.slug}`,
    },
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: "article",
      publishedTime: post.publishedAt,
      images: [{ url: post.ogImage, width: 1200, height: 630 }],
    },
    twitter: {
      card: "summary_large_image",
      title: post.title,
      description: post.excerpt,
    },
  };
}

Drei Details, die wichtiger sind, als sie aussehen:

Die Canonical-URL. Dynamic Routes sind die klassische Quelle für Duplicate Content: /blog/mein-post?utm_source=x, Trailing-Slash-Varianten, Groß-/Kleinschreibung im Slug. Ein selbstreferenzierendes Canonical auf jeder dynamischen Seite sagt Suchmaschinen, welche Version zählt. Das ist die günstigste Ranking-Versicherung, die du kaufen kannst.

404s müssen echte 404s sein. Wenn deine Datenquelle nichts liefert, ruf notFound() aus next/navigation auf. Eine Dynamic Route, die eine leere “Produkt nicht gefunden”-Hülle mit HTTP 200 rendert, ist eine Soft-404-Fabrik — Google crawlt tausende davon und stuft leise die Meinung über deine gesamte Seite herab.

Metadaten und Seite müssen übereinstimmen. generateMetadata und deine Page-Komponente holen dieselben Daten. Nutz das cache()-Muster von oben, damit sie nicht auseinanderdriften und du deine Datenkosten nicht verdoppelst.

Dynamische SEO-Metadaten werden aus einer Datenbank in Suchergebnisse generiert

Sitemaps für Dynamic Routes

Crawler entdecken dynamische Seiten über Links — aber eine Sitemap macht die Entdeckung zuverlässig, gerade bei tiefen oder neuen Seiten. In Next.js ist das eine einzige Datei:

// app/sitemap.ts
import type { MetadataRoute } from "next";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getAllPosts();
  return [
    { url: "https://example.com", lastModified: new Date() },
    ...posts.map((post) => ({
      url: `https://example.com/blog/${post.slug}`,
      lastModified: post.updatedAt,
    })),
  ];
}

Das ist eine vollständig dynamische sitemap.xml, neu generiert mit deinem Build (oder im Revalidierungs-Intervall). Kombiniert mit strukturierten Daten — einem kleinen Article- oder Product-JSON-LD-Script pro Seite — deckt das im Wesentlichen alles ab, was technisches SEO 2026 von einer Dynamic Route verlangt.

const jsonLd = {
  "@context": "https://schema.org",
  "@type": "Article",
  headline: post.title,
  datePublished: post.publishedAt,
  author: { "@type": "Organization", name: "GetMind" },
};

// im JSX:
<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>

Verlinken, Prefetching und Params in Client Components

Dynamic Routes sind nur die halbe Geschichte — du navigierst auch zu ihnen. Nutz immer next/link mit interpolierten Hrefs:

<Link href={`/blog/${post.slug}`}>{post.title}</Link>

Link prefetcht Routen im Viewport — wenn jemand klickt, ist die statische Seite oft schon geladen. Das ist ein großer Teil davon, warum gut gebaute Next.js-Seiten sich instant anfühlen.

In Client Components bekommst du params nicht als Props von der Route — nutze stattdessen die Navigation-Hooks:

"use client";
import { useParams, usePathname } from "next/navigation";

export function ShareButton() {
  const params = useParams<{ slug: string }>();
  const pathname = usePathname();
  // params.slug und pathname sind hier synchron verfügbar
}

useParams ist auf dem Client synchron — kein await. Die Promise-basierten Params gelten nur für Server Components, Layouts und die generate*-Funktionen.

Layouts bekommen übrigens auch Params. Ein app/shop/[category]/layout.tsx erhält params: Promise<{ category: string }> und kann Kategorie-Navigation um jede Produktseite rendern — aber beachte: Layouts re-rendern nicht bei Navigation zwischen ihren Kindern und können keine Params von Segmenten unter ihnen lesen.

Loading, Errors und Streaming

Dynamic Routes holen Daten, und Daten können langsam oder kaputt sein. Der App Router gibt jedem Routen-Segment zwei Begleiter:

  • loading.tsx — eine sofortige Lade-UI (Skeleton), die angezeigt wird, während der Server rendert. Unter der Haube wickelt das deine Seite in eine Suspense-Boundary und streamt das Ergebnis rein, sobald es fertig ist. Für On-Demand-Routen verwandelt das die gefühlte Performance: Die Hülle erscheint sofort, statt dass der Browser auf einen leeren Tab starrt.
  • error.tsx — eine clientseitige Error Boundary. Wirft dein Datenfetch eine Exception, bekommen Nutzer eine wiederherstellbare Fehler-UI mit reset()-Funktion statt einer abgestürzten Seite.
// app/blog/[slug]/loading.tsx
export default function Loading() {
  return <ArticleSkeleton />;
}

Zwei Dateien, vielleicht zwanzig Zeilen — und deine Dynamic Routes degradieren elegant statt katastrophal. Es gibt keine Ausrede, das wegzulassen.

Häufige Fehler, die echten Traffic kosten

Wir haben jeden davon selbst gemacht oder debuggt. Lern aus unserem Schmerz.

1. Params nicht awaiten. Der Next.js-15-Klassiker. Alte Tutorials zeigen params.slug; neuer Code braucht const { slug } = await params. Symptome: TypeScript-Fehler oder leere Params in Produktion.

2. Soft 404s. Eine freundliche “Hier ist nichts!”-Seite mit Status 200 für unbekannte Slugs. Immer notFound(). Wir haben zugesehen, wie das Crawl-Budget eines Shops von 40.000 Soft-404-Varianten-URLs aufgefressen wurde, bevor das gefixt war.

3. Keine Canonical-Tags. Query-Parameter, Groß-/Kleinschreibung, Trailing Slashes — jede Variante ist ohne Canonical eine Duplikat-Seite. Eine Zeile in generateMetadata fixt es.

4. Alles vorrendern. generateStaticParams mit 200.000 Produkt-Slugs bedeutet mehrstündige Builds und eine CI-Rechnung, die deinen CFO weinen lässt. Render das heiße Set vor, lass dynamicParams + ISR den Rest machen.

5. Unvalidierte Params in Daten-Queries. Ein Slug ist Nutzereingabe. Fließt er in rohes SQL oder einen Dateipfad, hast du einen Injection- oder Traversal-Vektor in deinen Router gebaut. Validiere das Format (/^[a-z0-9-]+$/ für Slugs), bevor es irgendwas Sensibles berührt — und lass deinen Next.js-Server niemals als root laufen. Wir haben aufgeschrieben, was passiert, wenn ein Next.js-Server gekapert wird — der Router ist oft die Eingangstür.

6. Dieselben Daten dreimal holen. generateMetadata, Params-Logik und die Seite rufen die Datenbank jeweils separat auf. Getter in cache() wickeln.

7. Route Handler für Seiten missbrauchen. Gelegentlich bauen Leute app/api/post/[id]/route.ts und fetchen das von der eigenen Seite. Du zahlst zwei Roundtrips, um eine Seite auszuliefern. Server Components können die Datenquelle direkt abfragen.

Dynamic Routes vs. Alternativen: Wann du sie nicht brauchst

Ehrliches Engineering heißt, die Grenzen zu kennen. Du brauchst keine Dynamic Route, wenn:

  • Das Seiten-Set winzig und handgeschrieben ist. Fünf statische Marketing-Seiten brauchen kein [slug]-Template und kein CMS. Ordner reichen.
  • Der Content voll statisch ist und du null Server willst. Mit output: 'export' funktionieren Dynamic Routes weiterhin — aber jeder Pfad muss aus generateStaticParams kommen, weil es keinen Server gibt, der unbekannte Slugs rendert. On-Demand-Rendering via dynamicParams fällt weg. (Genau aus diesem Grund läuft dieser Blog als Static Export.)
  • Du nach Query statt Pfad filterst. Filter, Sortierung, Paginierung gehören in searchParams, nicht in Pfad-Segmente. /products?sort=price ist eine Ansicht derselben Seite; /products/[category] ist eine andere Seite. Verwechselst du das, bekommst du entweder ein SEO-Chaos oder eine nicht teilbare UI.

Das mentale Modell, das hilft: Pfad-Segmente sind Identität, Query-Params sind Zustand. Hat das Ding eine eigene kanonische Existenz (ein Post, ein Produkt, ein Doc), verdient es ein dynamisches Segment. Ist es eine Art, das Ding anzusehen, ist es ein Query-Param.

Advanced Patterns: Middleware, i18n und Route Groups

Sobald die Basics sitzen, tauchen in fast jedem ernsthaften Projekt drei fortgeschrittene Muster auf.

Middleware vor Dynamic Routes. middleware.ts läuft, bevor der Router matcht — der richtige Ort für Redirects alter URLs in dein neues dynamisches Schema. Du migrierst von /artikel/123-mein-post.html auf /blog/mein-post? Ein Middleware-Matcher plus Redirect-Map erhält jahrelang aufgebaute Link-Power per 301:

// middleware.ts
import { NextResponse } from "next/server";

export function middleware(request: Request) {
  const url = new URL(request.url);
  const legacy = url.pathname.match(/^\/artikel\/\d+-(.+)\.html$/);
  if (legacy) {
    return NextResponse.redirect(
      new URL(`/blog/${legacy[1]}`, url), 301
    );
  }
}

Ohne das verbrennt jede Migration auf Dynamic Routes still deine bestehenden Rankings. Mit ihr ist der Umzug für Suchmaschinen unsichtbar.

Internationalisierung als dynamisches Segment. Das idiomatische i18n-Setup im App Router ist ein [lang]-Segment ganz oben: app/[lang]/blog/[slug]/page.tsx. Dein generateStaticParams gibt dann das Kreuzprodukt aus Locales und Slugs zurück, und generateMetadata ergänzt alternates.languages, damit jede Seite ihre Geschwister per hreflang ankündigt. Zwei dynamische Segmente, und deine gesamte Seite ist mehrsprachig mit korrekten SEO-Signalen — kein Plugin, kein separates Deployment.

Route Groups, um Templates zu trennen. Geklammerte Ordner wie (marketing) und (shop) erscheinen nicht in der URL, geben aber jeder Gruppe ein eigenes Root-Layout. Deine dynamischen Produktseiten können also in einer komplett anderen Hülle leben als deine dynamischen Blogposts, während beide saubere Top-Level-URLs behalten. Kombiniert mit dynamischen Segmenten ist das der Weg, wie große Seiten Dutzende Seitentypen organisieren, ohne dass ein einziges URL-Präfix Implementierungsdetails verrät.

Nichts davon ist exotisch. Es ist der Unterschied zwischen einem Next.js-Projekt, das gewachsen ist, und einem, das designt wurde.

Eine Produktions-Checkliste

Bevor du eine Dynamic Route shippst, geh das hier durch:

  1. Params awaited (Promise<{ ... }>-Typen korrekt)?
  2. generateStaticParams für die Seiten, die zählen, mit bewusster dynamicParams-Entscheidung?
  3. revalidate gesetzt, falls sich Content nach dem Deploy ändert?
  4. notFound() bei fehlenden Daten — keine Soft 404s?
  5. generateMetadata mit einzigartigem Title, Description, Canonical, Open Graph?
  6. Route in app/sitemap.ts enthalten?
  7. JSON-LD-strukturierte Daten, wo sinnvoll?
  8. loading.tsx und error.tsx vorhanden?
  9. Slug-Format validiert, bevor es die Datenschicht erreicht?
  10. Daten-Getter in cache() gewickelt?

Zehn Punkte, vielleicht eine Stunde Arbeit pro Routen-Template — und der Unterschied zwischen einer Demo und einem Produktionssystem.

Das Fazit

Dynamic Routes sind der Grund, warum Next.js von einer Landingpage auf eine Plattform mit hunderttausend Seiten skaliert, ohne sein mentales Modell zu ändern. Die Klammer-Syntax ist der einfache Teil. Das Handwerk liegt in der Rendering-Strategie: vorrendern, was heiß ist, regenerieren, was sich ändert, 404 für das, was nicht existiert — und jede Seite für Suchmaschinen beschreiben, als wäre sie handgeschrieben.

Bring generateStaticParams, dynamicParams und generateMetadata zum Zusammenspielen, und du hast Seiten, die gleichzeitig schnell zu bauen, schnell auszuliefern und leicht zu ranken sind. Lass es weg, und du hast einen sehr eleganten Weg, langsamen Duplicate Content auszuliefern.

Der Router ist nicht nur Plumbing. In Next.js ist er deine Performance-Strategie und deine SEO-Strategie in einer Ordnerstruktur. Behandle ihn so.