Next.js App Router Advanced mit Claude Code 2026
Der Next.js App Router hat Features die kaum jemand kennt — Parallel Routes für Modals, Intercepting Routes für Instagram-ähnliche UX, Middleware für Auth und alle vier Caching-Layers. Claude Code kennt alle Advanced Features für Next.js 15.
Inhaltsverzeichnis
Next.js 15 ist nicht nur ein React-Framework — es ist ein vollständiges Full-Stack-Ökosystem mit ausgefeilten Routing-, Caching- und Rendering-Konzepten. Wer nur die Basics kennt (page.tsx, layout.tsx, API-Routes), verschenkt enormes Potenzial. Dieser Guide zeigt die sechs Advanced Features, die Claude Code im Jahr 2026 souverän beherrscht und für dich implementiert.
Claude Code versteht die Ordnerstruktur des App Routers auf einer tiefen Ebene — es kennt die Konventionen, die Besonderheiten und die Pitfalls. Du beschreibst das Feature, Claude Code baut es richtig. Kein Stack Overflow, kein Trial-and-Error mit obskuren Fehlermeldungen.
1. Parallel Routes — @slot Konvention und Modal-Pattern
Das klassische Anwendungsbeispiel ist ein Dashboard mit mehreren unabhängigen Bereichen — Analytics-Graph auf der linken Seite, aktuelle Bestellungen rechts, beide laden separat und können unabhängig voneinander Fehler werfen. Claude Code baut dieses Pattern auf Anfrage vollständig auf.
app/ ├── dashboard/ │ ├── @analytics/ # Slot 1 — wird als Prop "analytics" übergeben │ │ ├── page.tsx │ │ ├── loading.tsx │ │ └── error.tsx │ ├── @orders/ # Slot 2 — wird als Prop "orders" übergeben │ │ ├── page.tsx │ │ └── loading.tsx │ ├── @modal/ # Slot 3: Modal-Slot │ │ ├── default.tsx # PFLICHT: null wenn kein Modal aktiv │ │ └── (.)photo/ │ │ └── [id]/ │ │ └── page.tsx │ ├── layout.tsx # Empfängt alle Slots als Props │ └── page.tsx
// app/dashboard/layout.tsx — Parallel Routes Layout import { ReactNode } from 'react' interface DashboardLayoutProps { children: ReactNode analytics: ReactNode // @analytics Slot orders: ReactNode // @orders Slot modal: ReactNode // @modal Slot } export default function DashboardLayout({ children, analytics, orders, modal, }: DashboardLayoutProps) { return ( <div className="dashboard-wrapper"> <main className="dashboard-main">{children}</main> <aside className="analytics-panel">{analytics}</aside> <section className="orders-section">{orders}</section> {modal} </div> ) }
Das Modal-Pattern mit Parallel Routes
Das mächtigste Parallel-Routes-Pattern ist das Modal-Pattern: Ein Foto-Klick auf einer Feed-Seite öffnet das Bild in einem Modal, während der Feed-Hintergrund erhalten bleibt. Bei direktem Aufruf der URL wird das Foto als eigene Seite angezeigt. Instagram und Pinterest nutzen genau dieses UX-Muster.
// app/dashboard/@modal/default.tsx — Pflicht-Datei! // Wenn kein Modal-Slot aktiv ist, MUSS default.tsx null zurückgeben export default function ModalDefault() { return null } // app/dashboard/@modal/(.)photo/[id]/page.tsx // (.) = gleiche Route-Ebene intercepten import { Modal } from '@/components/Modal' import { getPhoto } from '@/lib/photos' export default async function PhotoModal({ params, }: { params: { id: string } }) { const photo = await getPhoto(params.id) return ( <Modal> <img src={photo.url} alt={photo.alt} className="modal-image" /> <p className="modal-caption">{photo.caption}</p> </Modal> ) }
default.tsx ist bei Parallel Routes mit Intercepting Routes zwingend erforderlich. Fehlt diese Datei, wirft Next.js einen 404-Fehler wenn der Slot keine aktive Route hat. Claude Code fügt diese Datei automatisch hinzu.
2. Intercepting Routes — (.)route und (..)route Konventionen
Die Konvention basiert auf der relativen Tiefe der abzufangenden Route. Claude Code kennt alle vier Varianten und wählt die richtige automatisch basierend auf der gewünschten Ordnerstruktur.
| Prefix | Bedeutung | Beispiel |
|---|---|---|
(.) | Gleiche Route-Ebene | (.)photo/[id] fängt photo/[id] auf gleicher Ebene ab |
(..) | Eine Ebene höher | (..)photo/[id] fängt Route eine Ebene über dem aktuellen Segment ab |
(...) | Root-Ebene | (...)photo/[id] fängt Route vom App-Root ab |
(..)(..) | Zwei Ebenen höher | Zwei Ebenen aufsteigen vor dem Intercepten |
app/ ├── feed/ │ ├── page.tsx # Feed-Seite mit Foto-Grid │ ├── @modal/ │ │ ├── default.tsx # null — kein Modal aktiv │ │ └── (.)photo/ # (.) = gleiche Ebene wie /feed/photo/ │ │ └── [id]/ │ │ └── page.tsx # Modal-Ansicht bei Soft Nav │ └── layout.tsx # Empfängt @modal Slot └── photo/ └── [id]/ └── page.tsx # Vollseite bei Hard Nav / direktem URL
// app/feed/layout.tsx — Parallel + Intercepting Routes kombiniert export default function FeedLayout({ children, modal, }: { children: React.ReactNode modal: React.ReactNode }) { return ( <> {children} {modal} </> ) } // app/feed/page.tsx — Feed mit klickbaren Fotos import Link from 'next/link' import { getPhotos } from '@/lib/photos' export default async function FeedPage() { const photos = await getPhotos() return ( <div className="photo-grid"> {photos.map((photo) => ( <Link key={photo.id} href={`/photo/${photo.id}`}> <img src={photo.thumbnail} alt={photo.alt} /> </Link> ))} </div> ) // Bei Klick: Modal (Soft Nav) | Bei direktem URL: /photo/[id] Seite } // app/feed/@modal/(.)photo/[id]/page.tsx — Wird bei Soft Nav angezeigt import { PhotoModal } from '@/components/PhotoModal' export default async function InterceptedPhotoPage({ params, }: { params: { id: string } }) { return <PhotoModal photoId={params.id} /> }
router.back() — nicht via router.push(). Bei Push würde der User in der History feststecken. Claude Code implementiert das korrekt mit dem useRouter Hook im Client Component.
3. Middleware — Auth-Redirect, matcher config und Geo-Routing
mein-projekt/
├── app/
│ └── ...
├── middleware.ts # Im Root — NICHT in app/
├── next.config.ts
└── package.json
Auth-Redirect mit NextAuth / Clerk
// middleware.ts — Auth-basierte Redirects import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' import { getToken } from 'next-auth/jwt' export async function middleware(request: NextRequest) { const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET, }) const { pathname } = request.nextUrl // Geschützte Routen: /dashboard und alle Subrouten if (pathname.startsWith('/dashboard') && !token) { const loginUrl = new URL('/login', request.url) loginUrl.searchParams.set('callbackUrl', pathname) return NextResponse.redirect(loginUrl) } // Admin-Bereich: zusätzliche Rollenprüfung if (pathname.startsWith('/admin')) { if (!token || token.role !== 'admin') { return NextResponse.redirect(new URL('/403', request.url)) } } // Login-Seite: eingeloggte User zum Dashboard leiten if (pathname === '/login' && token) { return NextResponse.redirect(new URL('/dashboard', request.url)) } return NextResponse.next() } // matcher: Welche Routen werden von Middleware verarbeitet? export const config = { matcher: [ // Alle Pfade außer statische Assets und Next.js internals '/((?!_next/static|_next/image|favicon.ico|public).*)', // Explizite Pfade: cleaner und verständlicher '/dashboard/:path*', '/admin/:path*', '/login', ], }
Geo-basiertes Routing — Länder-spezifische Inhalte
// middleware.ts — Geo-Routing via Vercel Geo Headers import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' const GEO_BLOCKED_COUNTRIES = ['RU', 'BY'] const LOCALE_REDIRECTS: Record<string, string> = { 'DE': '/de', 'AT': '/de', 'CH': '/de', 'US': '/en', 'GB': '/en', } export function middleware(request: NextRequest) { // Vercel injiziert Geo-Daten als Header const country = request.headers.get('x-vercel-ip-country') ?? 'US' const { pathname } = request.nextUrl // Geo-Block: gesperrte Länder → 403 if (GEO_BLOCKED_COUNTRIES.includes(country)) { return NextResponse.redirect(new URL('/geo-blocked', request.url)) } // Sprach-Redirect: Root → lokalisierte Version if (pathname === '/' && LOCALE_REDIRECTS[country]) { return NextResponse.redirect( new URL(LOCALE_REDIRECTS[country], request.url) ) } // Custom Header hinzufügen (verfügbar in Server Components) const response = NextResponse.next() response.headers.set('x-user-country', country) return response } export const config = { matcher: [ '/', '/((?!api|_next/static|_next/image|favicon.ico).*)', ], }
'/dashboard/:path*') und Regex-Pattern. Das Pattern (?!_next/static) ist ein Negative Lookahead — es schließt alle Pfade aus, die mit _next/static beginnen. Claude Code generiert den richtigen matcher für jeden Use Case.
4. Edge Runtime — runtime: 'edge', Limitierungen und Use Cases
Edge Runtime aktivieren
// In einer Route Handler Datei (app/api/fast/route.ts) export const runtime = 'edge' // Edge Runtime für diese Route export async function GET(request: Request) { const { searchParams } = new URL(request.url) const name = searchParams.get('name') ?? 'World' return new Response( JSON.stringify({ message: `Hello, ${name}!`, runtime: 'edge' }), { headers: { 'Content-Type': 'application/json' } } ) } // In einem Server Component (app/fast-page/page.tsx) export const runtime = 'edge' // Gesamte Seite auf Edge rendern export default function FastPage() { return <h1>Diese Seite läuft auf der Edge Runtime</h1> }
Was ist erlaubt — was nicht?
| Feature | Edge Runtime | Node.js Runtime |
|---|---|---|
| Web APIs (fetch, Request, Response) | ✓ | ✓ |
| Web Crypto API | ✓ | ✓ |
| TextEncoder / TextDecoder | ✓ | ✓ |
| ReadableStream / WritableStream | ✓ | ✓ |
| Node.js fs (Dateisystem) | ✗ | ✓ |
| Node.js path, os, crypto | ✗ | ✓ |
| Native Module (*.node) | ✗ | ✓ |
| Prisma ORM (standard) | ✗ | ✓ |
| Prisma Accelerate | ✓ | ✓ |
| Drizzle ORM (mit HTTP-Driver) | ✓ | ✓ |
| Globale Latenz (Vercel) | ~5-15ms | ~50-200ms |
Ideale Use Cases für Edge Runtime
// Use Case 1: A/B-Testing auf Edge-Ebene export const runtime = 'edge' export async function GET(request: Request) { // Zufällige Variante zuweisen (kein DB-Call nötig) const variant = Math.random() > 0.5 ? 'A' : 'B' const response = NextResponse.next() response.cookies.set('ab-variant', variant, { maxAge: 60 * 60 * 24 * 30, // 30 Tage httpOnly: true, }) return response } // Use Case 2: JWT-Validierung ohne DB-Round-trip import { jwtVerify } from 'jose' // Edge-kompatibel! export async function middleware(request: NextRequest) { const token = request.cookies.get('session')?.value if (!token) { return NextResponse.redirect(new URL('/login', request.url)) } try { await jwtVerify( token, new TextEncoder().encode(process.env.JWT_SECRET) ) return NextResponse.next() } catch { return NextResponse.redirect(new URL('/login', request.url)) } } export const runtime = 'edge'
5. Metadata API — generateMetadata, opengraph-image.tsx, sitemap.ts und robots.ts
generateMetadata() — Dynamische Meta-Tags
// app/blog/[slug]/page.tsx — Dynamische Metadata import type { Metadata, ResolvingMetadata } from 'next' import { getPost } from '@/lib/posts' type Props = { params: { slug: string } searchParams: { [key: string]: string | string[] | undefined } } export async function generateMetadata( { params }: Props, parent: ResolvingMetadata ): Promise<Metadata> { const post = await getPost(params.slug) // Parent-Metadata erben (z.B. base OpenGraph-Image) const previousImages = (await parent).openGraph?.images || [] return { title: post.title, description: post.excerpt, authors: [{ name: post.author.name }], openGraph: { title: post.title, description: post.excerpt, type: 'article', publishedTime: post.publishedAt, images: [ { url: post.ogImage || `/api/og?title=${encodeURIComponent(post.title)}`, width: 1200, height: 630, alt: post.title, }, ...previousImages, ], }, twitter: { card: 'summary_large_image', title: post.title, description: post.excerpt, images: [post.ogImage], }, alternates: { canonical: `https://example.com/blog/${params.slug}`, }, } }
opengraph-image.tsx — Dynamische OG-Images
// app/blog/[slug]/opengraph-image.tsx // Next.js rendert dies als PNG automatisch import { ImageResponse } from 'next/og' import { getPost } from '@/lib/posts' export const runtime = 'edge' export const alt = 'Blog Post OG Image' export const size = { width: 1200, height: 630 } export const contentType = 'image/png' export default async function OGImage({ params, }: { params: { slug: string } }) { const post = await getPost(params.slug) return new ImageResponse( ( <div style={{ background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '60px', justifyContent: 'flex-end', }} > <div style={{ color: 'white', fontSize: 60, fontWeight: 700 }}> {post.title} </div> <div style={{ color: 'rgba(255,255,255,0.8)', fontSize: 28, marginTop: 16 }}> {post.author.name} — {new Date(post.publishedAt).toLocaleDateString('de-DE')} </div> </div> ), { ...size } ) }
sitemap.ts und robots.ts
// app/sitemap.ts — Dynamische Sitemap import type { MetadataRoute } from 'next' import { getAllPosts, getAllProducts } from '@/lib/data' export default async function sitemap(): Promise<MetadataRoute.Sitemap> { const posts = await getAllPosts() const products = await getAllProducts() const blogUrls = posts.map((post) => ({ url: `https://example.com/blog/${post.slug}`, lastModified: new Date(post.updatedAt), changeFrequency: 'weekly' as const, priority: 0.8, })) const productUrls = products.map((product) => ({ url: `https://example.com/products/${product.slug}`, lastModified: new Date(product.updatedAt), changeFrequency: 'daily' as const, priority: 0.9, })) return [ { url: 'https://example.com', changeFrequency: 'yearly', priority: 1 }, { url: 'https://example.com/blog', changeFrequency: 'weekly', priority: 0.9 }, ...blogUrls, ...productUrls, ] } // app/robots.ts — robots.txt generieren import type { MetadataRoute } from 'next' export default function robots(): MetadataRoute.Robots { return { rules: [ { userAgent: '*', allow: '/', disallow: ['/admin/', '/api/', '/private/'], }, { userAgent: 'Googlebot', allow: '/', }, ], sitemap: 'https://example.com/sitemap.xml', } }
6. Next.js Caching Layers — alle vier Ebenen erklärt
1. Request Memoization
Duplizierte fetch()-Calls mit gleicher URL + Options werden innerhalb eines Renders dedupliziert. Automatisch, kein Config nötig.
2. Data Cache
Persistent über Requests und Deployments. fetch() cached standardmäßig. Invalidierbar via revalidateTag() oder revalidatePath().
3. Full Route Cache
Gesamte gerenderte HTML+RSC-Payload wird auf dem Server gespeichert. Nur für statisch gerenderte Seiten. Wird bei Deployment neu gebaut.
4. Router Cache
Client-seitiger Cache im Browser. RSC-Payloads werden nach Navigation gespeichert. Läuft nach Zeit ab (30s für statisch, 0s für dynamisch).
Request Memoization — Automatische Deduplication
// Request Memoization: gleiche URL = einmal gefetcht // Auch wenn beide Komponenten in einer Render-Pass aufgerufen werden // UserAvatar.tsx async function UserAvatar({ userId }: { userId: string }) { const user = await fetch(`/api/users/${userId}`).then(r => r.json()) return <img src={user.avatar} alt={user.name} /> } // UserGreeting.tsx async function UserGreeting({ userId }: { userId: string }) { // GLEICHER fetch-Call → wird NICHT zweimal ausgeführt const user = await fetch(`/api/users/${userId}`).then(r => r.json()) return <p>Hallo, {user.name}!</p> } // → Nur 1 HTTP-Request, beide Komponenten bekommen gleiche Daten
Data Cache — Konfiguration und Invalidierung
// Data Cache Optionen bei fetch() const data = await fetch('https://api.example.com/posts', { next: { revalidate: 3600, // ISR: nach 3600s neu fetchen (time-based) tags: ['posts', 'blog'], // Tags für gezielte Invalidierung } }) // Kein Cache: immer frische Daten (Dynamic Rendering) const liveData = await fetch('https://api.example.com/live', { cache: 'no-store' }) // Server Action: Cache-Invalidierung on-demand // app/actions.ts 'use server' import { revalidateTag, revalidatePath } from 'next/cache' export async function publishPost(postId: string) { // Post in DB speichern... await savePostToDb(postId) // Data Cache für 'posts' Tag invalidieren revalidateTag('posts') // Oder spezifischen Pfad invalidieren revalidatePath('/blog') revalidatePath(`/blog/${postId}`) } // Route Handler: On-Demand Revalidation via Webhook // app/api/revalidate/route.ts export async function POST(request: Request) { const { searchParams } = new URL(request.url) const tag = searchParams.get('tag') const secret = searchParams.get('secret') if (secret !== process.env.REVALIDATION_SECRET) { return new Response('Unauthorized', { status: 401 }) } if (tag) { revalidateTag(tag) return new Response(`Revalidated tag: ${tag}`) } return new Response('No tag provided', { status: 400 }) }
Full Route Cache vs. Router Cache
// Full Route Cache: Seite als static markieren // app/blog/page.tsx export const revalidate = 3600 // ISR: alle Stunde neu generieren // oder: export const revalidate = 0 // Immer dynamisch (kein Full Route Cache) // oder: export const dynamic = 'force-static' // Immer static export const dynamic = 'force-dynamic' // Immer dynamic // generateStaticParams: Welche Seiten beim Build generiert werden // app/blog/[slug]/page.tsx export async function generateStaticParams() { const posts = await getAllPosts() return posts.map((post) => ({ slug: post.slug })) } // → Alle bekannten Blog-Posts werden beim Build statisch generiert // → Neue Posts: on-demand SSR + dann gecacht (ISR-Verhalten) // Router Cache: Client-seitiger Cache Reset 'use client' import { useRouter } from 'next/navigation' function RefreshButton() { const router = useRouter() return ( <button onClick={() => router.refresh()}> Seite aktualisieren </button> // router.refresh() = Router Cache leeren + Server neu abfragen ) }
next dev) ist der Data Cache standardmäßig deaktiviert — jeder Request fetcht frische Daten. Im Production-Build greift der Cache. Claude Code hilft bei der Analyse von Cache-Bugs mit gezielten Diagnose-Fragen.
Caching-Entscheidungsbaum
// Wann welche Caching-Strategie? // STATIC: Inhalt ändert sich selten (Blog, Docs, Marketing) export const revalidate = 86400 // 24h export const dynamic = 'force-static' // ISR: Inhalt ändert sich gelegentlich (E-Commerce, News) export const revalidate = 60 // 1 Minute // + revalidateTag() bei Mutations // DYNAMIC: Inhalt ist user-spezifisch (Dashboard, Profil) export const dynamic = 'force-dynamic' // + fetch mit cache: 'no-store' // HYBRID: Mischung (Layout statisch, Content dynamisch) // Layout: statisch + langer revalidate // Komponentenebene: Suspense + Streaming für dynamische Teile // unstable_noStore: Granulare Cache-Kontrolle auf Komponentenebene import { unstable_noStore as noStore } from 'next/cache' async function LiveCounter() { noStore() // Diese Komponente nie cachen const count = await getLiveCount() return <span>{count} online</span> }
Fazit: Claude Code als Next.js Advanced Experte
Die sechs Advanced Features — Parallel Routes, Intercepting Routes, Middleware, Edge Runtime, Metadata API und Caching — sind das, was Next.js von einfachen React-Setups unterscheidet. Sie sind leistungsstark, aber haben Konventionen und Pitfalls die schwer zu googeln sind.
Claude Code kennt alle diese Features auf einer tiefen, praktischen Ebene. Es wählt die richtige Ordnerstruktur (@slot, (.)-Prefix, default.tsx), schreibt korrekte TypeScript-Typen und erklärt warum es welche Entscheidung trifft. Das spart Stunden an Debugging und Stack-Overflow-Suchen.
Im Claude Code Mastery Kurs bauen wir ein vollständiges Next.js 15 Projekt das alle diese Features in einem realen Kontext einsetzt — von der Photosharing-App mit Parallel Routes über ein Auth-System mit Middleware bis hin zu einer optimierten E-Commerce-Seite mit ISR und On-Demand Revalidation.
Next.js-Modul im Kurs
Im Claude Code Mastery Kurs: Next.js 15 App Router von Grundlagen bis Advanced — Parallel Routes, Middleware, Edge Runtime, Metadata API und alle vier Caching-Layers.
14 Tage kostenlos testen →