Design wechseln

Next.js API Routes – Komplettguide: Von Route Handlers bis zu Best Practices für Fehlerbehandlung

Easton editorial illustration: performance inspection lens

Letzten Freitagnachmittag kam der Product Manager vorbei: „Können wir eine Benutzerregistrierungs-Schnittstelle hinzufügen?“ Ich öffnete den Ordner pages/api im Projekt, wollte nach dem bekannten Muster loslegen – und stellte fest, dass der Ordner leer war. Dann fiel mir ein: Das ist ein neues Projekt mit App Router, die API-Schreibweise hat sich komplett geändert.

In der Next.js-Dokumentation stand „Route Handlers“ – und ich dachte: schon wieder ein neues Konzept. Einen halben Tag lang habe ich Docs und Beispiele studiert, bis klar war, was route.ts ist und warum die vertrauten req- und res-Objekte nicht mehr gelten.

Wenn Sie bei der Backend-Schnittstellen-Entwicklung in Next.js unsicher sind, bringt dieser Artikel Ordnung ins Thema. Im Vergleich zeige ich, was sich zwischen Pages Router und App Router geändert hat, und anhand praktischer Beispiele, wie Sie Requests verarbeiten, Responses gestalten und Fehler sauber behandeln. Nach dem Lesen können Sie Next.js-Backend-Schnittstellen mit gutem Gefühl entwickeln.

API Routes Grundlagen: Der Wesensunterschied der beiden Schreibweisen

Schreibweise aus der Pages-Router-Ära

Vor Next.js 13 lagen Schnittstellen unter pages/api. Die Syntax erinnerte an Express – mit den Node.js-Objekten req und res:

// pages/api/hello.ts
import type { NextApiRequest, NextApiResponse } from 'next'

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  res.status(200).json({ message: 'Hello from Pages Router!' })
}

Vorteil: schneller Einstieg, besonders mit Node.js- oder Express-Erfahrung. Nachteil: Abhängigkeit von Node.js-spezifischen APIs – in Edge-Umgebungen (Edge Runtime) problematisch.

Route Handlers im App Router

Mit Next.js 13 und App Router änderte sich die API-Schreibweise grundlegend. Unter app legen Sie route.ts an und nutzen die Web-Standard-APIs Request und Response:

// app/api/hello/route.ts
export async function GET(request: Request) {
  return Response.json({ message: 'Hello from Route Handlers!' })
}

Beim ersten Mal wirkte das ungewohnt. Warum kein req/res mehr? Die Gründe:

  1. Web-Standards: Native Browser-APIs Request und Response – portabler und zeitgemäß
  2. Bessere Typsicherheit: Stärkere TypeScript-Unterstützung ohne Extra-Typdefinitionen
  3. Edge Runtime: Deployment auf Vercel Edge, Cloudflare Workers usw. – schnellere Antwortzeiten

Kernunterschiede im Überblick

EigenschaftPages RouterApp Router
Dateipfadpages/api/*app/*/route.ts
API-DesignNode.js req/resWeb-Standard Request/Response
HTTP-MethodenEin Default-Export, manuelles req.methodSeparate Exporte pro Methode (GET, POST …)
Cache-VerhaltenKein CacheGET standardmäßig gecacht

Anfangs wirkte die Umstellung überflüssig. Nach längerer Nutzung ist die neue Schreibweise klarer – besonders bei verschiedenen HTTP-Methoden ohne lange if (req.method === 'GET')-Ketten.

Route Handlers in der Praxis: HTTP-Requests erstellen und verarbeiten

Unterstützte HTTP-Methoden

Route Handlers unterstützen sieben Methoden: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. Jede Methode ist eine benannte Export-Funktion – die Struktur zeigt sofort, welche Operationen die Schnittstelle unterstützt.

Beispiel einer vollständigen Benutzerverwaltungs-Schnittstelle:

// app/api/users/route.ts

// Benutzerliste abrufen
export async function GET(request: Request) {
  // Query-Parameter aus der URL
  const { searchParams } = new URL(request.url)
  const page = searchParams.get('page') || '1'

  return Response.json({
    users: [
      { id: 1, name: 'Anna' },
      { id: 2, name: 'Bernd' }
    ],
    page: parseInt(page)
  })
}

// Neuen Benutzer anlegen
export async function POST(request: Request) {
  // JSON-Body parsen
  const body = await request.json()

  return Response.json({
    id: 3,
    name: body.name
  }, { status: 201 })
}

Request-Daten verarbeiten

Next.js Route Handlers bieten mehrere Wege – am Anfang leicht zu verwechseln:

  1. URL-Parameter: request.url mit dem URL-Objekt
const { searchParams } = new URL(request.url)
const keyword = searchParams.get('q')
  1. Request-Body (JSON): await request.json()
const body = await request.json()
console.log(body.email) // E-Mail-Feld auslesen
  1. Request-Body (FormData): await request.formData()
const formData = await request.formData()
const file = formData.get('avatar')
  1. Header und Cookies: Import aus next/headers
import { headers, cookies } from 'next/headers'

export async function GET() {
  const headersList = headers()
  const cookieStore = cookies()

  const token = headersList.get('authorization')
  const userId = cookieStore.get('user_id')

  // ...
}
  1. Dynamische Route-Parameter: Zweites Funktionsargument
// app/api/users/[id]/route.ts
export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  const userId = params.id
  return Response.json({ userId })
}

Ein Stolperstein: In Next.js 15+ ist params asynchron – dann await params.id nötig. Meist reicht die direkte Nutzung; TypeScript weist darauf hin.

Responses konstruieren

JSON ist der häufigste Fall – mit Response.json():

export async function GET() {
  return Response.json({
    success: true,
    data: { message: 'Vorgang erfolgreich' }
  })
}

Statuscode und Header setzen:

export async function POST(request: Request) {
  const body = await request.json()

  // Validierung fehlgeschlagen → 400
  if (!body.email) {
    return Response.json(
      { error: 'E-Mail darf nicht leer sein' },
      { status: 400 }
    )
  }

  // Erfolgreich erstellt → 201 mit Headern
  return Response.json(
    { id: 123, email: body.email },
    {
      status: 201,
      headers: {
        'X-Request-Id': 'abc-123',
        'Cache-Control': 'no-cache'
      }
    }
  )
}

Praxisbeispiel: Benutzerregistrierung

Alles zusammen – vollständige Registrierungs-Schnittstelle:

// app/api/auth/register/route.ts
import { headers } from 'next/headers'

export async function POST(request: Request) {
  // Request-Header auslesen
  const headersList = headers()
  const contentType = headersList.get('content-type')

  // Content-Type prüfen
  if (!contentType?.includes('application/json')) {
    return Response.json(
      { error: 'Bitte Daten im JSON-Format senden' },
      { status: 400 }
    )
  }

  // Body parsen
  const body = await request.json()
  const { username, email, password } = body

  // Basisvalidierung
  if (!username || !email || !password) {
    return Response.json(
      { error: 'Benutzername, E-Mail und Passwort dürfen nicht leer sein' },
      { status: 400 }
    )
  }

  // Hier Benutzer in der Datenbank speichern
  // const user = await db.user.create({ username, email, password })

  // Erfolgsantwort
  return Response.json({
    success: true,
    data: {
      id: 1,
      username,
      email
    }
  }, { status: 201 })
}

Das Beispiel deckt Header-Prüfung, JSON-Parsing, Validierung, Fehler- und Erfolgsantwort ab – das Muster für die meisten Schnittstellen.

Fehlerbehandlung: Stabilere und zuverlässigere APIs

Try-Catch richtig einsetzen

Am Anfang packte ich alles in einen großen try-catch:

// ❌ Nicht empfohlen: ein großer try-catch für die gesamte Logik
export async function POST(request: Request) {
  try {
    const body = await request.json()
    // viel Geschäftslogik ...
    return Response.json({ success: true })
  } catch (error) {
    return Response.json({ error: 'Vorgang fehlgeschlagen' }, { status: 500 })
  }
}

Problem: Alle Fehler werden 500 – das Frontend bekommt keine Details, Debugging wird mühsam. Besser: pro Operation getrennt behandeln:

// ✅ Empfohlen: Fehlertypen unterscheiden
export async function POST(request: Request) {
  let body

  // JSON-Parse-Fehler separat
  try {
    body = await request.json()
  } catch (error) {
    return Response.json(
      { error: 'Ungültiges Request-Format – JSON prüfen' },
      { status: 400 }
    )
  }

  // Validierungsfehler ohne try-catch
  if (!body.email || !body.password) {
    return Response.json(
      { error: 'E-Mail und Passwort dürfen nicht leer sein' },
      { status: 400 }
    )
  }

  // Datenbankfehler separat
  try {
    const user = await db.user.create(body)
    return Response.json({ success: true, data: user })
  } catch (error) {
    // Doppelte E-Mail?
    if (error.code === 'P2002') {
      return Response.json(
        { error: 'Diese E-Mail ist bereits registriert' },
        { status: 409 }
      )
    }

    // Andere DB-Fehler
    console.error('Database error:', error)
    return Response.json(
      { error: 'Serverfehler – bitte später erneut versuchen' },
      { status: 500 }
    )
  }
}

So werden Fehlermeldungen und Statuscodes für das Frontend klar.

Strukturierte Fehlerantworten

In vielen Projekten variiert das Fehlerformat – { error: '...' }, { message: '...' }, { msg: '...' }. Für das Frontend unangenehm.

Ein bewährtes Standardformat:

// Einheitliches Fehlerformat
interface ErrorResponse {
  success: false
  error: string          // Benutzerfreundliche Meldung
  code?: string          // Fehlercode für i18n
  details?: any          // Details (nur Entwicklung)
  requestId?: string     // Request-Tracking-ID
}

// Einheitliches Erfolgsformat
interface SuccessResponse<T> {
  success: true
  data: T
  requestId?: string
}

Hilfsfunktionen dazu:

// lib/api-response.ts
import { nanoid } from 'nanoid'

export function successResponse<T>(data: T, status: number = 200) {
  return Response.json({
    success: true,
    data,
    requestId: nanoid()
  }, { status })
}

export function errorResponse(
  error: string,
  status: number = 500,
  code?: string,
  details?: any
) {
  const isDev = process.env.NODE_ENV === 'development'

  return Response.json({
    success: false,
    error,
    code,
    details: isDev ? details : undefined, // In Produktion keine Details
    requestId: nanoid()
  }, { status })
}

Schnittstellen werden dadurch schlanker:

// app/api/users/[id]/route.ts
import { successResponse, errorResponse } from '@/lib/api-response'

export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  const userId = params.id

  try {
    const user = await db.user.findUnique({ where: { id: userId } })

    if (!user) {
      return errorResponse('Benutzer nicht gefunden', 404, 'USER_NOT_FOUND')
    }

    return successResponse(user)
  } catch (error) {
    return errorResponse(
      'Benutzerdaten konnten nicht geladen werden',
      500,
      'INTERNAL_ERROR',
      error
    )
  }
}

Erwartete vs. unerwartete Fehler

Zwei Kategorien:

  1. Erwartete Fehler: falsches Format, Ressource fehlt, keine Berechtigung – normale Geschäftslogik, Status 4xx
  2. Unerwartete Fehler: DB-Verbindung weg, Drittanbieter down, Bug – Systemfehler, Status 5xx

Unterschiedliche Behandlung:

export async function POST(request: Request) {
  const body = await request.json()

  // Erwarteter Fehler: kein Log nötig
  if (!body.email?.includes('@')) {
    return errorResponse('Ungültiges E-Mail-Format', 400, 'INVALID_EMAIL')
  }

  try {
    // Externe API
    const response = await fetch('https://api.example.com/verify', {
      method: 'POST',
      body: JSON.stringify({ email: body.email })
    })

    if (!response.ok) {
      // Drittanbieter-Fehler – erwartet
      return errorResponse('E-Mail-Verifizierung fehlgeschlagen', 400, 'VERIFICATION_FAILED')
    }

    return successResponse({ verified: true })
  } catch (error) {
    // Netzwerk, Timeout – unerwartet
    console.error('Unexpected error:', error) // Loggen

    // Monitoring, z. B. Sentry
    // Sentry.captureException(error)

    return errorResponse(
      'Dienst vorübergehend nicht verfügbar – bitte später erneut versuchen',
      503,
      'SERVICE_UNAVAILABLE'
    )
  }
}

Keine sensiblen Informationen preisgeben

Früher habe ich DB-Fehlermeldungen direkt ans Frontend zurückgegeben – und damit die Tabellenstruktur offengelegt. Richtig:

try {
  const user = await db.user.create(body)
  return successResponse(user)
} catch (error) {
  // ❌ Gefährlich: Roher Fehler
  // return errorResponse(error.message, 500)

  // ✅ Sicher: Generische Meldung, Details nur im Log
  console.error('Database error:', {
    error,
    userId: request.headers.get('user-id'),
    timestamp: new Date().toISOString()
  })

  return errorResponse(
    'Benutzer konnte nicht erstellt werden – bitte später erneut versuchen',
    500,
    'CREATE_USER_FAILED'
  )
}

Entwicklung vs. Produktion:

const isDev = process.env.NODE_ENV === 'development'

return Response.json({
  success: false,
  error: 'Vorgang fehlgeschlagen',
  // Details nur in der Entwicklung
  stack: isDev ? error.stack : undefined,
  details: isDev ? error : undefined
}, { status: 500 })

Response-Design: Schlüssel zur Frontend-Backend-Zusammenarbeit

RESTful-API-Design

Ich orientiere mich an REST-Prinzipien – nicht dogmatisch, aber die Regeln machen APIs verständlicher.

Kernpunkte:

  1. HTTP-Methoden für Aktionen:

    • GET: Ressource lesen
    • POST: Ressource anlegen
    • PUT/PATCH: Ressource aktualisieren
    • DELETE: Ressource löschen
  2. URLs für Ressourcen:

    • /api/users – Benutzerliste
    • /api/users/123 – Benutzer mit ID 123
    • /api/users/123/posts – Beiträge dieses Benutzers
  3. Statuscodes für Ergebnisse:

    • 200: Erfolg
    • 201: Erfolgreich erstellt
    • 400: Client-Parameterfehler
    • 401: Nicht angemeldet
    • 403: Keine Berechtigung
    • 404: Ressource nicht gefunden
    • 500: Serverfehler

Beispiel Benutzerverwaltung:

// app/api/users/route.ts
export async function GET(request: Request) {
  // GET /api/users?page=1&limit=20
  const { searchParams } = new URL(request.url)
  const page = parseInt(searchParams.get('page') || '1')
  const limit = parseInt(searchParams.get('limit') || '20')

  const users = await db.user.findMany({
    skip: (page - 1) * limit,
    take: limit
  })

  return Response.json({ success: true, data: users })
}

export async function POST(request: Request) {
  // POST /api/users
  const body = await request.json()
  const user = await db.user.create({ data: body })

  return Response.json(
    { success: true, data: user },
    { status: 201 } // Hier 201 verwenden
  )
}

// app/api/users/[id]/route.ts
export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  // GET /api/users/123
  const user = await db.user.findUnique({ where: { id: params.id } })

  if (!user) {
    return Response.json(
      { success: false, error: 'Benutzer nicht gefunden' },
      { status: 404 }
    )
  }

  return Response.json({ success: true, data: user })
}

export async function PATCH(
  request: Request,
  { params }: { params: { id: string } }
) {
  // PATCH /api/users/123
  const body = await request.json()
  const user = await db.user.update({
    where: { id: params.id },
    data: body
  })

  return Response.json({ success: true, data: user })
}

export async function DELETE(
  request: Request,
  { params }: { params: { id: string } }
) {
  // DELETE /api/users/123
  await db.user.delete({ where: { id: params.id } })

  return Response.json({ success: true, data: null })
}

Einheitliches Response-Format

Ergänzung zum Fehlerkapitel – aktuelles Format:

// Basis-Response-Typ
type ApiResponse<T> =
  | { success: true; data: T }
  | { success: false; error: string; code?: string }

// Paginierte Antwort
interface PaginatedResponse<T> {
  success: true
  data: T[]
  pagination: {
    page: number
    limit: number
    total: number
    totalPages: number
  }
}

// Listen-Response-Beispiel
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const page = parseInt(searchParams.get('page') || '1')
  const limit = parseInt(searchParams.get('limit') || '20')

  const [users, total] = await Promise.all([
    db.user.findMany({ skip: (page - 1) * limit, take: limit }),
    db.user.count()
  ])

  return Response.json({
    success: true,
    data: users,
    pagination: {
      page,
      limit,
      total,
      totalPages: Math.ceil(total / limit)
    }
  })
}

TypeScript-Typdefinitionen

Frontend und Backend teilen sich TypeScript – ein types-Ordner im Projekt:

// types/api.ts
export interface User {
  id: string
  username: string
  email: string
  createdAt: string
}

export interface CreateUserRequest {
  username: string
  email: string
  password: string
}

export interface CreateUserResponse {
  success: true
  data: User
}

// types/api-client.ts
import type { CreateUserRequest, CreateUserResponse } from './api'

export async function createUser(data: CreateUserRequest) {
  const response = await fetch('/api/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data)
  })

  const result: CreateUserResponse = await response.json()

  if (!result.success) {
    throw new Error(result.error)
  }

  return result.data
}

So hat der Frontend-Aufruf vollständige Typunterstützung.

Häufige Probleme und Lösungen

Standard-Caching bei GET-Requests

Der unangenehmste Stolperstein: Benutzerdaten-Schnittstelle lokal ok, nach Deployment alte Daten im Frontend – weil GET standardmäßig gecacht wird.

Next.js geht davon aus, dass viele GET-Antworten statisch sind. In der Praxis sind die meisten GET-Requests dynamisch.

Lösung – eine Zeile in route.ts:

// app/api/users/route.ts
export const dynamic = 'force-dynamic' // Cache deaktivieren

export async function GET() {
  const users = await db.user.findMany()
  return Response.json({ success: true, data: users })
}

Weitere Optionen:

// Methode 2: revalidate auf 0
export const revalidate = 0

// Methode 3: Cache-Control im Response-Header
export async function GET() {
  const users = await db.user.findMany()

  return Response.json(
    { success: true, data: users },
    {
      headers: {
        'Cache-Control': 'no-store, max-age=0'
      }
    }
  )
}

Wann Caching sinnvoll ist? Kaum ändernde Daten – Länderliste, Kategorien:

// app/api/countries/route.ts
export const revalidate = 3600 // 1 Stunde Cache

export async function GET() {
  const countries = await db.country.findMany()
  return Response.json({ success: true, data: countries })
}

API 404 nach Deployment

Lokal alles ok, auf Vercel oder Netlify alles 404 – typische Ursachen:

  1. Falscher Dateiname: muss route.ts oder route.js heißen, nicht api.ts
  2. Falscher Pfad: route.ts nicht im selben Ordner wie page.tsx
  3. Nicht in Git: .gitignore prüfen, Route-Datei committed?

Checkliste:

# ✅ Korrekte Struktur
app/
  api/
    users/
      route.ts          # korrekt: GET /api/users
    users/
      [id]/
        route.ts        # korrekt: GET /api/users/123

# ❌ Falsche Struktur
app/
  api/
    users.ts            # falsch: sollte route.ts heißen
  users/
    page.tsx
    route.ts            # falsch: nicht neben page.tsx

Außerdem: next.config.js darf app nicht ausschließen:

// next.config.js
module.exports = {
  // Diese Zeile würde app ignorieren – vermeiden
  // pageExtensions: ['page.tsx', 'page.ts'],
}

Redirect in try-catch

Next.js redirect() wirft einen speziellen Fehler – in try-catch wird die Weiterleitung abgefangen:

import { redirect } from 'next/navigation'

// ❌ Falsch
export async function GET() {
  try {
    const isLoggedIn = await checkAuth()

    if (!isLoggedIn) {
      redirect('/login') // wird vom catch gefangen
    }

    return Response.json({ success: true })
  } catch (error) {
    return Response.json({ error: 'Vorgang fehlgeschlagen' }, { status: 500 })
  }
}

// ✅ Richtig
export async function GET() {
  const isLoggedIn = await checkAuth()

  if (!isLoggedIn) {
    redirect('/login') // außerhalb von try-catch
  }

  try {
    const data = await fetchData()
    return Response.json({ success: true, data })
  } catch (error) {
    return Response.json({ error: 'Vorgang fehlgeschlagen' }, { status: 500 })
  }
}

In Route Handlers ist redirect() selten nötig – oft reicht Status 401, Frontend übernimmt die Navigation.

Wann Route Handlers nicht nötig sind

Am Anfang dachte ich, jede Datenerfassung braucht eine API. Server Components können Backend-Logik direkt aufrufen – ohne HTTP-Umweg.

Beispiel:

// ❌ Nicht empfohlen: API schreiben, dann aus Komponente aufrufen
// app/api/posts/route.ts
export async function GET() {
  const posts = await db.post.findMany()
  return Response.json({ success: true, data: posts })
}

// app/blog/page.tsx
async function BlogPage() {
  const res = await fetch('http://localhost:3000/api/posts')
  const { data } = await res.json()

  return <div>{/* Beitragsliste rendern */}</div>
}

// ✅ Empfohlen: Server Component fragt direkt ab
// app/blog/page.tsx
async function BlogPage() {
  const posts = await db.post.findMany() // Direkt DB

  return <div>{/* Beitragsliste rendern */}</div>
}

Wann Route Handlers?

  1. Externe Aufrufe: Mobile App, Drittanbieter
  2. Webhooks: Callbacks externer Dienste
  3. Datenänderungen aus Client Components: Formulare, Löschen
  4. Komplexe Logik: Datei-Upload, mehrere externe APIs

Wann nicht?

  1. Daten in Server Components: direkte DB-Abfrage ist schneller
  2. Einfache Formulare: Server Actions sind einfacher
  3. Interne Navigation: Next.js-Routing reicht

Fortgeschrittene Techniken: Professionellere APIs

Eingabevalidierung

Manuelle if-Ketten sind mühsam – ich nutze Zod:

import { z } from 'zod'

// Validierungsregeln
const createUserSchema = z.object({
  username: z.string().min(3).max(20),
  email: z.string().email(),
  password: z.string().min(8),
  age: z.number().min(18).optional()
})

export async function POST(request: Request) {
  const body = await request.json()

  // Validieren
  const result = createUserSchema.safeParse(body)

  if (!result.success) {
    return Response.json({
      success: false,
      error: 'Validierung fehlgeschlagen',
      details: result.error.errors // Detaillierte Validierungsfehler
    }, { status: 400 })
  }

  // result.data ist validiert und typsicher
  const user = await db.user.create({ data: result.data })

  return Response.json({ success: true, data: user }, { status: 201 })
}

Vorteil von Zod: Validierung und Typen in einem Schritt:

// TypeScript-Typ aus Schema ableiten
type CreateUserInput = z.infer<typeof createUserSchema>
// Entspricht:
// type CreateUserInput = {
//   username: string
//   email: string
//   password: string
//   age?: number
// }

Middleware-Muster

Bei mehreren Schnittstellen wiederholt sich Code – Auth, Logging, Fehlerbehandlung. Als Middleware abstrahieren:

// lib/middleware.ts
type RouteHandler = (request: Request, context: any) => Promise<Response>

// Auth-Middleware
export function withAuth(handler: RouteHandler): RouteHandler {
  return async (request, context) => {
    const token = request.headers.get('authorization')

    if (!token) {
      return Response.json(
        { success: false, error: 'Nicht angemeldet' },
        { status: 401 }
      )
    }

    // Token prüfen
    const user = await verifyToken(token)

    if (!user) {
      return Response.json(
        { success: false, error: 'Token ungültig' },
        { status: 401 }
      )
    }

    // Benutzerinfo an Handler übergeben
    context.user = user

    return handler(request, context)
  }
}

// Logging-Middleware
export function withLogging(handler: RouteHandler): RouteHandler {
  return async (request, context) => {
    const start = Date.now()
    const { method, url } = request

    console.log(`[${method}] ${url} – Start`)

    const response = await handler(request, context)

    const duration = Date.now() - start
    console.log(`[${method}] ${url} – Fertig (${duration}ms)`)

    return response
  }
}

// Kombiniert
// app/api/profile/route.ts
import { withAuth, withLogging } from '@/lib/middleware'

async function getProfile(request: Request, context: any) {
  const user = context.user // Aus Middleware

  return Response.json({ success: true, data: user })
}

export const GET = withLogging(withAuth(getProfile))

In Projekten kombiniere ich das mit Zod:

// lib/middleware.ts
export function withValidation<T>(
  schema: z.Schema<T>,
  handler: (request: Request, data: T, context: any) => Promise<Response>
): RouteHandler {
  return async (request, context) => {
    const body = await request.json()
    const result = schema.safeParse(body)

    if (!result.success) {
      return Response.json({
        success: false,
        error: 'Validierung fehlgeschlagen',
        details: result.error.errors
      }, { status: 400 })
    }

    return handler(request, result.data, context)
  }
}

// Verwendung
export const POST = withAuth(
  withValidation(createUserSchema, async (request, data, context) => {
    // data ist validiert und typsicher
    const user = await db.user.create({ data })
    return Response.json({ success: true, data: user }, { status: 201 })
  })
)

Edge Runtime wählen

Next.js bietet Node.js Runtime und Edge Runtime. Standard Node.js reicht meist; für globale niedrige Latenz Edge:

// app/api/hello/route.ts
export const runtime = 'edge' // Edge Runtime

export async function GET() {
  return Response.json({ message: 'Hello from Edge!' })
}

Vorteile: schnelle Antworten am Edge-Knoten. Einschränkungen:

  1. Keine Node.js-APIs: fs, path usw. nicht verfügbar
  2. Keine klassische DB-Verbindung: HTTP-fähige DBs, z. B. Prisma Data Proxy, PlanetScale
  3. Bundle-Größe: zu großer Code wird nicht deployed

Wann Edge Runtime?

  • Einfache APIs ohne schwere Abhängigkeiten
  • Read-heavy Szenarien
  • Globale niedrige Latenz

Wann Node.js Runtime?

  • Klassische Datenbankverbindung
  • Node.js-Ökosystem-Bibliotheken
  • Komplexe Geschäftslogik

Die meisten Projekte laufen mit Standard-Node.js; Edge passt für spezifische Fälle.

Fazit

Das Wesentliche zu Next.js API Routes im Überblick:

  • Schreibweise: Von Pages Router req/res zu App Router Request/Response – Web-Standards
  • Route Handlers: Separate Exporte pro HTTP-Methode – klar strukturiert
  • Request-Verarbeitung: URL-Parameter, JSON-Body, FormData, Header, Cookies, dynamische Routen
  • Fehlerbehandlung: Erwartete vs. unerwartete Fehler, einheitliches Format, keine sensiblen Leaks
  • Response-Design: REST-Prinzipien, semantische Statuscodes, geteilte TypeScript-Typen
  • Typische Fallen: GET-Cache, 404 nach Deployment, redirect in try-catch, zu viele Route Handlers

Der Wechsel von Pages zu App Router ist spürbar – nach der Gewöhnungsphase sind die Vorteile klar: bessere Typsicherheit, übersichtlicherer Code, flexibleres Deployment.

Als Nächstes:

  1. Direkt ausprobieren: Eine bestehende Schnittstelle als Route Handler umschreiben
  2. Vorlage anlegen: Fehlerbehandlung und Response-Format aus diesem Artikel als API-Template sichern
  3. Weiter lernen: Next.js entwickelt sich schnell – Docs zu Server Actions, Middleware usw. im Blick behalten

Es gibt kein Patentrezept – passen Sie die Ansätze an Ihr Projekt an.

Wenn der Artikel geholfen hat, bookmarken Sie ihn für später. Viel Erfolg mit Next.js!

FAQ

Was sind die Hauptunterschiede zwischen Route Handlers und Pages-Router-API-Routes?
Es gibt drei zentrale Unterschiede: 1) Route Handlers nutzen die Web-Standard-Request/Response-API, der Pages Router arbeitet mit Node.js req/res; 2) Jede HTTP-Methode wird separat exportiert, ohne manuelles Prüfen von req.method; 3) GET-Requests werden standardmäßig gecacht – zum Deaktivieren dynamic='force-dynamic' konfigurieren.
Warum liefert mein GET-Request nicht die neuesten Daten?
GET-Requests im App Router werden standardmäßig gecacht. Lösung: export const dynamic = 'force-dynamic' in route.ts setzen oder Cache-Control: no-store in den Response-Headern. Caching eignet sich nur für Schnittstellen mit statischen Daten.
Wann Route Handlers und wann Server Components?
Route Handlers brauchen Sie für: externe API-Aufrufe, Webhooks, Datenänderungen aus Client Components, Datei-Uploads. Nicht nötig: Server Components können die Datenbank direkt abfragen; einfache Formularübermittlung geht bequemer mit Server Actions.
Wie behandelt man API-Fehler sauber?
Erwartete Fehler (4xx) von unerwarteten Fehlern (5xx) trennen, einheitliches Format { success, error, code, requestId } verwenden. Pro Operation separat try-catch – kein großer try-catch um die gesamte Logik. In Produktion keine sensiblen Fehlerdetails an den Client, nur ins Log schreiben.
Nach dem Deployment sind alle APIs 404, lokal aber alles ok – was tun?
Drei Punkte prüfen: 1) Dateiname muss route.ts oder route.js heißen; 2) route.ts darf nicht im selben Ordner wie page.tsx liegen; 3) Datei in Git committed und next.config.js schließt app nicht aus. Häufiger Fehler: falscher Dateiname oder -pfad.

15 Min. Lesezeit · Veröffentlicht am: 5. Jan. 2026 · Aktualisiert am: 14. Juli 2026

Ähnliche Beiträge

Kommentare

Melde dich mit GitHub an, um einen Kommentar zu hinterlassen

Easton BlogEaston Blog