Design wechseln

Next.js Dynamisches Routing & Parameter-Handling: Vom Einstieg bis zur Typsicherheit

Easton editorial illustration: server-client bridge

Letzte Woche beim Refactoring eines Next.js-Projekts: dynamische Route exakt wie in der Doku – Klick liefert 404. Die Konsole blieb still, kein Fehler. Erst später fiel auf: Im App Router von Next.js 14 hat sich die Art geändert, wie Routenparameter kommen; ich hing noch an Pages-Router-Gewohnheiten.

Das war nicht mein erster Stolperer auf Next.js-Routing. Von getStaticPaths zu generateStaticParams – bei jedem großen Sprung neu lernen. Wann ein dynamisches Segment, wann Catch-all, was optional Catch-all bedeutet – leicht vermischt.

Wenn Sie bei Next.js Dynamic Routing unsicher sind oder von Pages Router zum App Router wechseln, ist dieser Artikel für Sie. Von den Grundlagen bis zu typsicheren Mustern – mit viel echtem Code.

Was Sie mitnehmen: ein klares Modell der Routentypen, korrektes Auslesen von params und TypeScript-Typen dafür – konkreter Code, keine Floskeln.

Kapitel 1: Grundlagen dynamischer Routen

Was ist dynamisches Routing?

Typisches Szenario: Blog, URL /blog/artikel-id. Mit statischen Routen bräuchten Sie pro Artikel eine eigene Datei – unrealistisch. Dynamic Routing: eine page.tsx für alle Detailseiten.

Im Next.js App Router entstehen dynamische Routen durch Ordner in eckigen Klammern:

app/
├── blog/
│   └── [slug]/
│       └── page.tsx    ← dynamische Route

Das matcht alle /blog/*-Pfade, z. B.:

  • /blog/hello-worldslug = "hello-world"
  • /blog/nextjs-guideslug = "nextjs-guide"
  • /blog/123slug = "123"

Minimalbeispiel

app/blog/[slug]/page.tsx:

// app/blog/[slug]/page.tsx
export default function BlogPost({
  params
}: {
  params: { slug: string }
}) {
  return (
    <div>
      <h1>Artikeldetails</h1>
      <p>Aktueller Slug: {params.slug}</p>
    </div>
  )
}

Bei /blog/hello-world ist params.slug gleich "hello-world".

Häufige Anfängerfehler:

  1. ❌ Datei [slug].tsx (App Router braucht einen Ordner)
  2. props.slug direkt (Werte kommen aus params)
  3. ❌ Klammern im Ordnernamen vergessen (ohne [ ] ist die Route statisch)

Pages Router vs. App Router

MerkmalPages RouterApp Router
Dateipages/blog/[slug].tsxapp/blog/[slug]/page.tsx
Parameterrouter.query.slug oder getStaticPropsparams.slug
Typenoft manuellüber Props-Typen
Statische PfadegetStaticPathsgenerateStaticParams

Beim Umstieg stört oft: Im Pages Router ging useRouter; in Server Components gibt es keinen Client-Router – nur params als Prop, weil standardmäßig serverseitig gerendert wird.

Praxis: Produktseite im Shop

URL /products/produkt-id:

// app/products/[id]/page.tsx
interface Product {
  id: string
  name: string
  price: number
  description: string
}

async function getProduct(id: string): Promise<Product | null> {
  const products: Product[] = [
    { id: '1', name: 'TypeScript-Einstieg', price: 99, description: 'Für Einsteiger' },
    { id: '2', name: 'React in der Praxis', price: 129, description: 'Vom Nullpunkt bis Live' }
  ]
  return products.find(p => p.id === id) || null
}

export default async function ProductPage({
  params
}: {
  params: { id: string }
}) {
  const product = await getProduct(params.id)

  if (!product) {
    return <div>Produkt nicht gefunden</div>
  }

  return (
    <div>
      <h1>{product.name}</h1>
      <p className="price">¥{product.price}</p>
      <p>{product.description}</p>
    </div>
  )
}

Details:

  1. async Server Component
  2. Daten zuerst, dann Render
  3. Fehlendes Produkt abgefangen

Für echte 404: notFound aus next/navigation:

import { notFound } from 'next/navigation'

export default async function ProductPage({
  params
}: {
  params: { id: string }
}) {
  const product = await getProduct(params.id)

  if (!product) {
    notFound()
  }

  return (
    <div>
      <h1>{product.name}</h1>
      {/* ... */}
    </div>
  )
}

Damit greift Ihre not-found.tsx – bessere UX.

Damit sind die Basics drin. Als Nächstes: mehrstufige Pfade mit Catch-all.

Kapitel 2: Catch-all und optionale Parameter

Wann Catch-all?

Dokumentation mit variabler Tiefe:

  • /docs/getting-started
  • /docs/api/authentication
  • /docs/api/database/queries
  • /docs/guides/deployment/vercel

Ein einfaches [slug] reicht nicht – hier Catch-all mit [...slug].

Catch-all: [...slug]

app/
├── docs/
│   └── [...slug]/
│       └── page.tsx

Matcht z. B.:

  • /docs/getting-startedslug = ["getting-started"]
  • /docs/api/authenticationslug = ["api", "authentication"]
  • /docs/guides/deployment/vercelslug = ["guides", "deployment", "vercel"]

Wichtig: slug ist ein Array, kein String.

Code: Dokumentationssystem

// app/docs/[...slug]/page.tsx
interface Doc {
  title: string
  content: string
}

async function getDoc(slugArray: string[]): Promise<Doc | null> {
  const path = slugArray.join('/')

  const docs: Record<string, Doc> = {
    'getting-started': {
      title: 'Schnellstart',
      content: 'Willkommen bei unserem Produkt...'
    },
    'api/authentication': {
      title: 'API-Authentifizierung',
      content: 'Wir nutzen JWT...'
    },
    'api/database/queries': {
      title: 'Datenbankabfragen',
      content: 'Abfragen mit Prisma...'
    }
  }

  return docs[path] || null
}

export default async function DocsPage({
  params
}: {
  params: { slug: string[] }
}) {
  const doc = await getDoc(params.slug)

  if (!doc) {
    return <div>Dokument nicht gefunden</div>
  }

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

      <nav>
        <a href="/docs">Dokumentation</a>
        {params.slug.map((segment, i) => {
          const href = `/docs/${params.slug.slice(0, i + 1).join('/')}`
          return (
            <span key={i}>
              {' / '}
              <a href={href}>{segment}</a>
            </span>
          )
        })}
      </nav>
    </article>
  )
}

Highlights: join('/') für den Schlüssel, Breadcrumb mit slice, Typ slug: string[].

Optional Catch-all: [[...slug]]

Soll /docs und /docs/* dieselbe Route bedienen, reicht [...slug] nicht (mindestens ein Segment nötig). Dann optional Catch-all:

app/
├── docs/
│   └── [[...slug]]/
│       └── page.tsx

Matcht:

  • /docsslug = undefined
  • /docs/getting-startedslug = ["getting-started"]
  • /docs/api/authslug = ["api", "auth"]
// app/docs/[[...slug]]/page.tsx
export default async function DocsPage({
  params
}: {
  params: { slug?: string[] }
}) {
  if (!params.slug) {
    return <div>Willkommen im Dokumentationszentrum</div>
  }

  const doc = await getDoc(params.slug)
  // ...
}

Typische Fallen

Falle 1: Array vergessen

// ❌
<h1>Pfad: {params.slug}</h1>

// ✅
<h1>Pfad: {params.slug.join('/')}</h1>

Falle 2: falsches Format in generateStaticParams

// ❌
{ slug: 'api/auth' }

// ✅
{ slug: ['api', 'auth'] }

Falle 3: Routentypen verwechseln

TypOrdnerMatchParametertyp
Dynamisch[slug]/blog/123string
Catch-all[...slug]/docs/a/b (nicht /docs)string[]
Optional[[...slug]]/docs und /docs/a/bstring[] | undefined

Sonderzeichen in URLs

export default async function Page({
  params
}: {
  params: { slug: string[] }
}) {
  const decodedSlug = params.slug.map(s => decodeURIComponent(s))

  console.log(params.slug)
  console.log(decodedSlug)

  // ...
}

Als Nächstes: wann Seiten beim Build vs. on-demand entstehen – generateStaticParams.

Kapitel 3: generateStaticParams im Detail

Warum?

100 Blogposts unter /blog/[slug]. Ohne Optimierung pro Request: DB-Query, SSR, Antwort – langsam und teuer. Build-Zeit-Prerender aller Slugs: das ist generateStaticParams.

// app/blog/[slug]/page.tsx
interface Post {
  slug: string
  title: string
  content: string
}

export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts').then(r => r.json())

  return posts.map((post: Post) => ({
    slug: post.slug
  }))
}

export default async function BlogPost({
  params
}: {
  params: { slug: string }
}) {
  const post = await fetch(`https://api.example.com/posts/${params.slug}`)
    .then(r => r.json())

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

Ablauf:

  1. generateStaticParams läuft beim Build
  2. Next.js erzeugt pro Slug statisches HTML
  3. Auslieferung aus dem Cache – sehr schnell

Build-Artefakte (vereinfacht):

.next/server/app/blog/
├── hello-world.html
├── nextjs-guide.html
└── typescript-tips.html

Wann ja, wann nein?

Sinnvoll:

  • Blog, News (relativ stabil)
  • Produktdetails (begrenzte Anzahl, z. B. < 10.000)
  • Docs, Help Center
  • Profile (moderate Nutzerzahl)

Ungeeignet:

  • Suche (unendliche Kombinationen)
  • Echtzeit (Börse, Live-Scores)
  • große UGC-Plattformen
  • stark personalisierte Seiten

Catch-all statisch

// app/docs/[...slug]/page.tsx
export async function generateStaticParams() {
  const docPaths = [
    ['getting-started'],
    ['api', 'authentication'],
    ['api', 'database', 'queries'],
    ['guides', 'deployment', 'vercel']
  ]

  return docPaths.map(slug => ({ slug }))
}

Format: { slug: ['api', 'auth'] }, nicht ein String mit Schrägstrichen.

Mehrere Parameter

/shop/[category]/[productId]:

// app/shop/[category]/[productId]/page.tsx
export async function generateStaticParams() {
  const products = [
    { category: 'electronics', productId: 'iphone-15' },
    { category: 'electronics', productId: 'macbook-pro' },
    { category: 'books', productId: 'clean-code' },
    { category: 'books', productId: 'refactoring' }
  ]

  return products.map(p => ({
    category: p.category,
    productId: p.productId
  }))
}

export default async function ProductPage({
  params
}: {
  params: { category: string; productId: string }
}) {
  return (
    <div>
      <h1>Kategorie: {params.category}</h1>
      <p>Produkt-ID: {params.productId}</p>
    </div>
  )
}

On-demand (Fallback-ähnlich)

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

export async function generateStaticParams() {
  const topPosts = await fetchTopPosts(100)

  return topPosts.map(post => ({
    slug: post.slug
  }))
}

export default async function BlogPost({
  params
}: {
  params: { slug: string }
}) {
  const post = await fetchPost(params.slug)

  if (!post) {
    notFound()
  }

  return <article>{/* ... */}</article>
}

Mit dynamicParams = true:

  • vorgerenderte Seiten: sofort
  • andere: beim ersten Hit generiert und gecacht
  • unbekannte Slugs: 404

Häufige Fragen

Wann läuft die Funktion? Nur bei npm run build, nicht bei jedem Request. Im Dev-Modus sieht man den Effekt erst nach Build.

Daten aktualisiert? Statisches HTML ist eingefroren bis zum nächsten Deploy. Optionen: ISR mit revalidate, dynamicParams, oder gezielt weniger Pfade vorab rendern.

export const revalidate = 60

export default async function Page() {
  // ...
}

Build zu lang? Weniger Einträge in generateStaticParams, inkrementelles Hosting, oder mehr on-demand.

Kapitel 4: Typsichere Routenparameter

Warum Typen?

export default async function Page({
  params
}: {
  params: { slug: string }
}) {
  const id = parseInt(params.slug)

  if (isNaN(id)) {
    return <div>Ungültige ID</div>
  }

  // ...
}

slug ist string, Sie brauchen eine Zahl – der Compiler warnt nicht, der Fehler kommt zur Laufzeit.

Basis-Typen

interface BlogParams {
  slug: string
}

export default async function BlogPost({
  params
}: {
  params: BlogParams
}) {
  const post = await fetchPost(params.slug)
}

Bei mehreren Segmenten lohnt sich Enge:

interface ShopParams {
  category: 'electronics' | 'books' | 'clothing'
  productId: string
}

export default async function ProductPage({
  params
}: {
  params: ShopParams
}) {
  if (params.category === 'toys') {
    // ❌ TypeScript-Fehler
  }
}

Laufzeit: Zod

npm install zod
import { z } from 'zod'
import { notFound } from 'next/navigation'

const paramsSchema = z.object({
  id: z.string().regex(/^\d+$/, 'Muss numerische ID sein')
})

export default async function ProductPage({
  params
}: {
  params: { id: string }
}) {
  const result = paramsSchema.safeParse(params)

  if (!result.success) {
    notFound()
  }

  const { id } = result.data
  const product = await fetchProduct(parseInt(id))
}

Compile-time plus Runtime; illegale URLs werden 404, ohne DB-Last.

Typisiertes generateStaticParams

interface BlogParams {
  slug: string
}

export async function generateStaticParams(): Promise<BlogParams[]> {
  const posts = await fetchAllPosts()

  return posts.map(post => ({
    slug: post.slug
  }))
}

Mehrsprachiger Blog

/[locale]/blog/[slug]:

import { z } from 'zod'
import { notFound } from 'next/navigation'

const locales = ['zh', 'en', 'ja'] as const
type Locale = typeof locales[number]

interface PageParams {
  locale: Locale
  slug: string
}

const paramsSchema = z.object({
  locale: z.enum(locales),
  slug: z.string().min(1)
})

export async function generateStaticParams(): Promise<PageParams[]> {
  const posts = await fetchAllPosts()

  return locales.flatMap(locale =>
    posts.map(post => ({
      locale,
      slug: post.slug
    }))
  )
}

export default async function BlogPost({
  params
}: {
  params: PageParams
}) {
  const result = paramsSchema.safeParse(params)
  if (!result.success) {
    notFound()
  }

  const { locale, slug } = result.data
  const post = await fetchPost(slug, locale)

  if (!post) {
    notFound()
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  )
}

params als Promise (Next.js 15+)

export default async function Page({
  params
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
}

In Next.js 14 kann params noch synchron sein – dann direkt nutzen.

Typ-Probleme

  • params als any: strict in tsconfig.json, korrekte Dateinamen (page.tsx)
  • Zod-Fehler loggen: result.error.format()

Checkliste

  • Jede dynamische Route hat einen params-Typ
  • generateStaticParams-Rückgabe passt zu params
  • kritische Routen mit Zod
  • strict aktiv
  • Union/Literal-Typen wo sinnvoll

Fazit

Sie haben jetzt ein zusammenhängendes Bild von Next.js Dynamic Routing:

Basis: [slug], params auslesen
Catch-all: [...slug] und optional [[...slug]]
generateStaticParams: Build vs. on-demand
Typsicherheit: TypeScript + Zod

Der Unterschied Pages vs. App Router sollte klar sein – inklusive SSG-Strategie.

Nächste Schritte

Sofort:

  • eine dynamische Route anlegen und params testen
  • bei Bedarf Catch-all probieren
  • TypeScript- und Zod-Validierung ergänzen

Vertiefung:

  • Parallel Routes (@folder)
  • Intercepting Routes ((.)folder)
  • Route Groups (folder)
  • Middleware für Auth und Redirects

Offizielle Docs:

Kurzreferenz

ProblemPrüfenLösung
404Ordnername, generateStaticParamsKlammer-Syntax, statische Pfade
params = anyTS-Configstrict, Typen definieren
langer BuildAnzahl Pfadeweniger SSG, dynamicParams
veraltete DatenCacherevalidate / ISR

Der Wechsel zum App Router war für viele (mich eingeschlossen) holprig – danach wirkt das Modell oft klarer. Routing ist das Fundament; darauf bauen Data Fetching, Caching und Middleware leichter auf.

Bei Problemen: Troubleshooting in der Doku, GitHub-Issues, Next.js-Discord.

Legen Sie los – bauen Sie Ihre dynamischen Routen. 🚀

Vollständiger Workflow für Next.js Dynamic Routing

Von der Anlage dynamischer Routen bis zur typsicheren Praxis

⏱️ Estimated time: 2 hr

  1. 1

    Step 1: Ordner für dynamische Routen anlegen

    Je nach Anforderung den Routentyp wählen:
    • Ein Parameter: app/posts/[id]/page.tsx
    • Mehrere Parameter: app/posts/[category]/[id]/page.tsx
    • Catch-all: app/posts/[...slug]/page.tsx
    • Optional Catch-all: app/posts/[[...slug]]/page.tsx

    Namensregeln:
    • [id]: Pflichtparameter
    • [...slug]: alle Pfadsegmente erfassen
    • [[...slug]]: optional alle Pfadsegmente erfassen
  2. 2

    Step 2: Routenparameter auslesen

    In page.tsx Parameter holen:
    • App Router nutzt das params-Objekt
    • params ist ein Promise und muss mit await aufgelöst werden
    • Konkrete Werte per Destrukturierung

    Beispiel:
    export default async function Page({ params }) {
    const { id } = await params
    return <div>Post {id}</div>
    }

    Hinweis: params muss await haben, sonst Fehler
  3. 3

    Step 3: Typsicherheit einrichten

    Mit TypeScript typisieren:
    • Interface für params definieren
    • Promise<{ params }> als Typ verwenden
    • Rückgabetyp von generateStaticParams festlegen

    Beispiel:
    interface PageProps {
    params: Promise<{ id: string }>
    }

    export default async function Page({ params }: PageProps) {
    const { id } = await params
    // ...
    }
  4. 4

    Step 4: Statische Generierung (optional)

    generateStaticParams nutzen:
    • alle möglichen Parameterkombinationen zurückgeben
    • async-Funktion zum Datenladen möglich
    • für statische Vorab-Generierung aller Seiten

    Beispiel:
    export async function generateStaticParams() {
    const posts = await getPosts()
    return posts.map(post => ({ id: post.id }))
    }

    Hinweis: nur für statische Generierung; rein dynamische Routen brauchen es nicht
  5. 5

    Step 5: Optionale Parameter behandeln

    Optional Catch-all:
    • Syntax [[...slug]]
    • params.slug kann undefined sein
    • Existenz prüfen

    Beispiel:
    export default async function Page({ params }) {
    const { slug } = await params
    if (!slug) {
    return <div>All posts</div>
    }
    return <div>Category: {slug.join('/')}</div>
    }
  6. 6

    Step 6: Testen und validieren

    Testschwerpunkte:
    • alle Routen erreichbar?
    • Parameter korrekt?
    • TypeScript-Hinweise stimmen?
    • statische Generierung erfolgreich?

    Checkliste:
    • alle dynamischen Routen erreichbar
    • Parametertypen korrekt
    • generateStaticParams liefert vollständige Daten
    • 404-Fälle abgedeckt

FAQ

Wie hole ich dynamische Routenparameter?
Im App Router über das params-Objekt.

Wichtig:
• params ist ein Promise – await ist Pflicht
• Destrukturierung für einzelne Werte
• Typen explizit definieren

Beispiel:
export default async function Page({ params }) {
const { id } = await params
return <div>{id}</div>
}
Warum liefert eine dynamische Route 404?
Mögliche Ursachen:
• falscher Ordnername (muss [id] sein, nicht {id})
• Pfad passt nicht zur URL-Struktur
• generateStaticParams unvollständig
• page.tsx fehlt

Lösung:
• Klammer-Syntax prüfen
• URL mit Ordnerstruktur abgleichen
• Rückgabe von generateStaticParams prüfen
Unterschied Catch-all und optional Catch-all?
Catch-all [...slug]:
• mindestens ein Pfadsegment nötig
• /posts/[...slug] matcht /posts/a, nicht /posts

Optional [[...slug]]:
• 0 oder mehr Segmente
• /posts/[[...slug]] matcht /posts und /posts/a/b

Einsatz:
• Catch-all: mindestens ein Parameter
• optional: Parameter können fehlen
Wie implementiere ich typsichere dynamische Routen?
Schritte:
1) params-Interface definieren
2) Promise<{ params }> typisieren
3) Rückgabetyp von generateStaticParams festlegen

Beispiel:
interface PageProps {
params: Promise<{ id: string }>
}

export default async function Page({ params }: PageProps) {
const { id } = await params
// ...
}
Wann generateStaticParams verwenden?
Zum statischen Vorab-Generieren aller möglichen Seiten.

Geeignet:
• alle Parameterwerte bekannt
• SEO und Performance durch SSG

Ungeeignet:
• Parameter ändern sich ständig
• zu viele Kombinationen
• Echtzeitdaten nötig

Hinweis: nur für statische Generierung
Migration von Pages Router?
Hauptänderungen:
• getStaticPaths → generateStaticParams
• context.params → params (mit await)
• Rückgabe: Array statt { paths, fallback }

Schritte:
1) getStaticPaths ersetzen
2) await params nutzen
3) Typen anpassen
4) alle Routen testen
Mehrere dynamische Parameter?
Mehrstufige Ordner:
app/posts/[category]/[id]/page.tsx

Parameter:
export default async function Page({ params }) {
const { category, id } = await params
return <div>{category} - {id}</div>
}

generateStaticParams für alle Kombinationen:
export async function generateStaticParams() {
return [
{ category: 'tech', id: '1' },
{ category: 'tech', id: '2' },
// ...
]
}

9 Min. Lesezeit · Veröffentlicht am: 25. Dez. 2025 · Aktualisiert am: 14. Juli 2026

Ähnliche Beiträge

Kommentare

Melde dich mit GitHub an, um einen Kommentar zu hinterlassen

Easton BlogEaston Blog