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

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-world→slug = "hello-world"/blog/nextjs-guide→slug = "nextjs-guide"/blog/123→slug = "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:
- ❌ Datei
[slug].tsx(App Router braucht einen Ordner) - ❌
props.slugdirekt (Werte kommen ausparams) - ❌ Klammern im Ordnernamen vergessen (ohne
[ ]ist die Route statisch)
Pages Router vs. App Router
| Merkmal | Pages Router | App Router |
|---|---|---|
| Datei | pages/blog/[slug].tsx | app/blog/[slug]/page.tsx |
| Parameter | router.query.slug oder getStaticProps | params.slug |
| Typen | oft manuell | über Props-Typen |
| Statische Pfade | getStaticPaths | generateStaticParams |
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:
asyncServer Component- Daten zuerst, dann Render
- 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-started→slug = ["getting-started"]/docs/api/authentication→slug = ["api", "authentication"]/docs/guides/deployment/vercel→slug = ["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:
/docs→slug = undefined/docs/getting-started→slug = ["getting-started"]/docs/api/auth→slug = ["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
| Typ | Ordner | Match | Parametertyp |
|---|---|---|---|
| Dynamisch | [slug] | /blog/123 | string |
| Catch-all | [...slug] | /docs/a/b (nicht /docs) | string[] |
| Optional | [[...slug]] | /docs und /docs/a/b | string[] | 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:
generateStaticParamsläuft beim Build- Next.js erzeugt pro Slug statisches HTML
- 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
paramsalsany:strictintsconfig.json, korrekte Dateinamen (page.tsx)- Zod-Fehler loggen:
result.error.format()
Checkliste
- Jede dynamische Route hat einen
params-Typ -
generateStaticParams-Rückgabe passt zuparams - kritische Routen mit Zod
-
strictaktiv - 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
paramstesten - 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
| Problem | Prüfen | Lösung |
|---|---|---|
| 404 | Ordnername, generateStaticParams | Klammer-Syntax, statische Pfade |
params = any | TS-Config | strict, Typen definieren |
| langer Build | Anzahl Pfade | weniger SSG, dynamicParams |
| veraltete Daten | Cache | revalidate / 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
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
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
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
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
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
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?
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?
• 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?
• 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?
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?
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?
• 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?
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
Next.js Komplettleitfaden
Wenn du über die Suche hier gelandet bist, kommst du am schnellsten weiter, indem du zum vorherigen oder nächsten Beitrag dieser Serie springst.
Vorheriger
Next.js App Router in der Praxis: Route Groups und Nested Layouts gegen Verzeichnis-Chaos in großen Projekten
Mit Route Groups, Nested Layouts, Parallel Routes und Intercepting Routes lösen Sie Verzeichnis-Chaos, Routing-Konflikte und Team-Kollaborationsprobleme in großen Next.js-Projekten – inklusive direkt übernehmbarer Verzeichnisstruktur.
Teil 5 von 51
Nächster
Next.js App Router: Häufige Fallstricke und Lösungen – 8 praxiserprobte Tipps
Vom Datenabruf bis zur Fehlerbehandlung: 14 typische Next.js App Router-Fallstricke und ihre Lösungen. Server Components, Client Components, Caching, Migration – Praxiswissen, mit dem Sie 80 % der häufigen Fehler vermeiden.
Teil 7 von 51
Ähnliche Beiträge
Next.js App Router Einstieg: Kernkonzepte und Grundlagen im Überblick

Next.js App Router Einstieg: Kernkonzepte und Grundlagen im Überblick
Next.js 15 in der Praxis: Wie ich an einem Wochenende ein produktionsreifes Blog-System aufgebaut habe


Kommentare
Melde dich mit GitHub an, um einen Kommentar zu hinterlassen