Route Handlers und APIs
Bisher haben wir Daten entweder direkt in Server Components gelesen oder über Server Actions geschrieben. Das reicht für eine klassische Web-App vollkommen aus. Doch sobald eine mobile App, ein externes Tool oder ein Webhook-Dienst mit eurer Anwendung sprechen soll, braucht ihr echte HTTP-Endpunkte — eine JSON-API. Genau dafür gibt es in Next.js die Route Handlers.
Route Handlers – REST-APIs in Next.js
Ein Route Handler ist eine Datei namens route.ts (oder route.js), die ihr in einem beliebigen Verzeichnis unter app/ ablegt. Die Datei exportiert benannte Funktionen, deren Namen HTTP-Methoden entsprechen: GET, POST, PUT, PATCH, DELETE, HEAD und OPTIONS. Next.js registriert diese Funktionen automatisch als HTTP-Endpunkte.
app/
api/
notes/
route.ts ← GET /api/notes, POST /api/notes
[id]/
route.ts ← GET /api/notes/42, PUT /api/notes/42, DELETE /api/notes/42Das Muster sollte euch bekannt vorkommen: In Rails definiert ihr in config/routes.rb eure Routen und implementiert die Logik in einem Controller. Bei Route Handlers fallen Routing-Konfiguration und Controller-Logik in einer einzigen Datei zusammen — route.ts ist gleichzeitig die Route-Registrierung und der Controller.
# config/routes.rb
namespace :api do
resources :notes, only: [:index, :create]
end
# app/controllers/api/notes_controller.rb
class Api::NotesController < ApplicationController
respond_to :json
def index
@notes = current_user.notes.order(created_at: :desc)
render json: @notes
end
def create
@note = current_user.notes.build(note_params)
if @note.save
render json: @note, status: :created
else
render json: @note.errors, status: :unprocessable_entity
end
end
end// app/api/notes/route.ts
// Routing-Konfiguration und Controller in einer Datei
export async function GET() {
const session = await getSession();
if (!session.userId) {
return NextResponse.json(
{ error: "Nicht angemeldet" },
{ status: 401 }
);
}
const notes = await prisma.note.findMany({
where: { userId: session.userId },
orderBy: { createdAt: "desc" },
});
return NextResponse.json(notes);
}
export async function POST(request: NextRequest) {
// ... Validierung und Erstellung
}Ein wichtiger Unterschied zu Rails: route.ts antwortet immer mit JSON (oder einem anderen gewünschten Format) — es gibt kein View-Rendering, kein Template, kein ERB. Die Datei verhält sich wie ein Rails-Controller, in dem ihr ausschließlich respond_to :json definiert habt.
Wann Route Handlers, wann Server Actions?
Diese Frage taucht in jedem Next.js-Projekt auf. Die gute Nachricht: Es gibt eine klare Faustregel. Server Actions sind für Interaktionen gedacht, die aus eurem Browser heraus ausgelöst werden — ein Formular, das abgeschickt wird, ein Button, der eine Datenbankoperation startet. Route Handlers sind für alles, was über HTTP von außen auf eure Anwendung zugreift.
| Anwendungsfall | Empfehlung | |---|---| | Formular-Submit aus dem Browser | Server Action | | CRUD-Operationen aus einer React-Komponente | Server Action | | Mobile App braucht Daten | Route Handler | | Externes Tool / Third-Party-Integration | Route Handler | | Webhook empfangen (Stripe, GitHub, …) | Route Handler | | Datei-Download / Datei-Streaming | Route Handler | | CSV- oder PDF-Export | Route Handler | | WebSocket / Server-Sent Events | Route Handler |
Die meisten Browser-Apps kommen mit Server Actions aus und brauchen gar keine Route Handlers. Route Handlers sind der "Escape Hatch" für Szenarien, in denen ihr eine echte HTTP-API braucht.
Ein weiterer Unterschied: Server Actions werden intern via POST aufgerufen und erfordern keine manuell aufgebauten fetch-Requests im Client-Code. Route Handlers antworten auf konkrete URLs und können von jedem HTTP-Client aufgerufen werden — curl, fetch, Postman, eine iOS-App oder ein Python-Skript.
HTTP-Methoden in route.ts
Für jede HTTP-Methode, die der Endpunkt unterstützen soll, exportiert ihr eine gleichnamige Funktion. Nicht exportierte Methoden antworten automatisch mit 405 Method Not Allowed.
// app/api/notes/route.ts
import { NextRequest, NextResponse } from "next/server";
// GET /api/notes — Liste aller Notizen
export async function GET(request: NextRequest): Promise<NextResponse> {
const notes = await fetchNotes();
return NextResponse.json(notes);
}
// POST /api/notes — Neue Notiz anlegen
export async function POST(request: NextRequest): Promise<NextResponse> {
const body = await request.json();
const note = await createNote(body);
return NextResponse.json(note, { status: 201 });
}
// DELETE ist nicht exportiert → antwortet mit 405 Method Not AllowedNextResponse.json() ist ein Shortcut, der den Content-Type: application/json-Header setzt und das übergebene Objekt serialisiert. Der optionale zweite Parameter nimmt ein ResponseInit-Objekt entgegen — dort setzt ihr den HTTP-Statuscode, zusätzliche Header und so weiter.
Für andere Content-Types könnt ihr new Response() direkt verwenden:
// Klartext zurückgeben
export async function GET() {
return new Response("pong", {
status: 200,
headers: { "Content-Type": "text/plain" },
});
}
// Datei-Download mit korrekten Headern
export async function GET() {
const csv = await generateCsv();
return new Response(csv, {
headers: {
"Content-Type": "text/csv",
"Content-Disposition": 'attachment; filename="notes.csv"',
},
});
}Dynamische Route Handler mit [id]
Genau wie bei Pages und Layouts könnt ihr Segment-Parameter in eckigen Klammern verwenden. Seit Next.js 15 werden params als Promise übergeben und müssen mit await aufgelöst werden:
// app/api/notes/[id]/route.ts
interface RouteContext {
params: Promise<{ id: string }>;
}
// GET /api/notes/42
export async function GET(
request: NextRequest,
context: RouteContext
): Promise<NextResponse> {
const { id } = await context.params; // await ist Pflicht!
const note = await prisma.note.findUnique({
where: { id: Number(id) },
include: { tags: true },
});
if (!note) {
return NextResponse.json({ error: "Nicht gefunden" }, { status: 404 });
}
return NextResponse.json(note);
}
// DELETE /api/notes/42
export async function DELETE(
request: NextRequest,
context: RouteContext
): Promise<NextResponse> {
const { id } = await context.params;
await prisma.note.delete({ where: { id: Number(id) } });
return new Response(null, { status: 204 }); // No Content
}# app/controllers/api/notes_controller.rb
def show
@note = Note.find(params[:id])
render json: @note
rescue ActiveRecord::RecordNotFound
render json: { error: "Nicht gefunden" },
status: :not_found
end
def destroy
@note = Note.find(params[:id])
@note.destroy!
head :no_content
end// app/api/notes/[id]/route.ts
export async function GET(request, context) {
const { id } = await context.params;
const note = await prisma.note.findUnique({
where: { id: Number(id) },
});
if (!note) {
return NextResponse.json(
{ error: "Nicht gefunden" },
{ status: 404 }
);
}
return NextResponse.json(note);
}
export async function DELETE(request, context) {
const { id } = await context.params;
await prisma.note.delete({ where: { id: Number(id) } });
return new Response(null, { status: 204 });
}Der wichtigste Unterschied zum Umgang mit nicht existierenden Records: In Rails wirft find automatisch eine RecordNotFound-Exception, die ihr in einem Rescue-Block abfangen könnt. Bei Prisma gibt findUnique stattdessen null zurück — ihr müsst also manuell prüfen und den 404-Response selbst zurückgeben.
Request und Response
Das request-Objekt ist eine Instanz von NextRequest, das die Web-Standard-Request-API erweitert. Darüber kommt ihr an alle Informationen der eingehenden Anfrage:
export async function GET(request: NextRequest) {
// Query-Parameter lesen: /api/notes?tag=typescript&limit=10
const searchParams = request.nextUrl.searchParams;
const tag = searchParams.get("tag"); // "typescript"
const limit = searchParams.get("limit"); // "10" (immer ein String!)
// Request-Header lesen
const authorization = request.headers.get("Authorization");
const contentType = request.headers.get("Content-Type");
// Authentifizierungstoken aus dem Bearer-Schema extrahieren
const token = authorization?.replace("Bearer ", "");
return NextResponse.json({ tag, limit });
}
export async function POST(request: NextRequest) {
// JSON-Body lesen und parsen
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: "Ungültiges JSON" },
{ status: 400 }
);
}
// FormData lesen (z.B. für Datei-Uploads)
// const formData = await request.formData();
// const file = formData.get("file") as File;
// Response mit eigenem Header zurückgeben
return NextResponse.json(
{ success: true },
{
status: 201,
headers: {
"X-Created-Id": "42",
"Cache-Control": "no-store",
},
}
);
}Ein häufiger Fehler: Query-Parameter sind immer Strings. searchParams.get("limit") liefert "10", nicht 10. Ihr müsst explizit nach Number() oder parseInt() konvertieren.
NextResponse bietet außerdem die statischen Methoden NextResponse.redirect(url) und NextResponse.rewrite(url), die ihr aus Middleware-Kontexten kennt. In Route Handlers sind sie allerdings selten sinnvoll — lieber einen JSON-Fehler zurückgeben als still umzuleiten.
Die bestehende API testen
Das Capstone-Projekt hat bereits einen fertigen Route Handler unter app/api/notes/route.ts. Ihr könnt ihn sofort im Browser oder mit curl ausprobieren — ohne Postman oder andere Tools installieren zu müssen.
Im Browser: Navigiert zu http://localhost:3000/api/notes. Der Browser sendet einen GET-Request und zeigt euch die JSON-Antwort. Falls ihr nicht eingeloggt seid, seht ihr:
{ "error": "Nicht angemeldet" }Nach dem Login erhaltet ihr eure Notizen als JSON-Array:
[
{
"id": 1,
"title": "Erste Notiz",
"content": "...",
"createdAt": "2025-01-15T10:30:00.000Z",
"tags": [{ "id": 1, "name": "typescript" }]
}
]Mit curl könnt ihr alle HTTP-Methoden testen. Die Session-Cookie-basierte Authentifizierung des Capstone-Projekts macht das etwas aufwändiger, aber für schnelle Tests reicht der Browser vollkommen aus:
# GET — alle Notizen (ohne Auth → 401)
curl http://localhost:3000/api/notes
# POST — neue Notiz anlegen
curl -X POST http://localhost:3000/api/notes \
-H "Content-Type: application/json" \
-d '{"title":"Test","content":"Inhalt","tags":["curl"]}'
# Mit Session-Cookie (nach Browser-Login aus Dev Tools kopieren)
curl http://localhost:3000/api/notes \
-H "Cookie: session=<euer-cookie-wert>"Der Handler in app/api/notes/route.ts unterstützt GET und POST. Ein DELETE-Request würde mit 405 Method Not Allowed antworten, weil diese Methode nicht exportiert ist.
Server Actions vs Route Handlers – Zusammenfassung
Wir haben jetzt beide Wege gesehen, mit denen Next.js Datenoperationen abwickelt. Hier noch einmal die wichtigsten Unterschiede auf einen Blick:
Server Actions:
- Werden direkt aus React-Komponenten aufgerufen — kein
fetch, keine URL - Funktionieren mit
<form action={...}>ohne JavaScript (Progressive Enhancement) - Haben Zugriff auf den Session-Kontext und die Prisma-Instanz wie jeder Server-Code
- Sind für Browser-Interaktionen optimiert
- Antworten nicht mit einem HTTP-Statuscode, den ihr selbst steuert
- Können
revalidatePath()aufrufen, um den Next.js-Cache gezielt zu leeren
Route Handlers:
- Sind echte HTTP-Endpunkte mit einer URL
- Können von jeder HTTP-Client-Anwendung aufgerufen werden
- Ihr steuert Status-Codes, Headers und den Response-Body vollständig
- Kein automatisches Cache-Invalidieren — ihr müsst selbst die
Cache-Control-Header setzen - Ideal für Webhooks, mobile Apps und externe Integrationen
- Lassen sich einfach mit
curlund Browser-Dev-Tools debuggen
In der Praxis verwenden die meisten Next.js-Apps beides: Server Actions für die Benutzeroberfläche, Route Handlers für die "API-Schicht", die nach außen hin exponiert wird. Das Capstone-Projekt ist ein gutes Beispiel dafür: Die Notizen-App läuft vollständig über Server Actions — aber app/api/notes/route.ts stellt dieselben Daten als JSON-API bereit, falls eine mobile App oder ein externes Tool darauf zugreifen möchte.
- Startet die Entwicklungsumgebung mit
docker compose up(falls noch nicht aktiv). - Öffnet
http://localhost:3000im Browser und loggt euch ein. - Navigiert direkt zu
http://localhost:3000/api/notes— ihr seht eure Notizen als JSON. - Öffnet die Browser-Dev-Tools (F12) → Tab "Network" und ladet die Seite neu. Seht ihr den GET-Request an
/api/notes? - Loggt euch aus und ruft
http://localhost:3000/api/noteserneut auf. Ihr solltet jetzt{"error":"Nicht angemeldet"}mit Status 401 sehen. - Bonus: Öffnet die Dev-Tools-Konsole und führt diesen Fetch-Aufruf aus:
Nach dem erneuten Einloggen liefert derselbe Aufruf euer Notizen-Array.
fetch("/api/notes").then(r => r.json()).then(console.log)
Im Capstone: DevNotes
Im Capstone-Projekt (app/api/notes/route.ts) dient der Route Handler als Demonstrations-Endpunkt für Modul 10. Er zeigt, wie GET und POST in einer einzigen Datei implementiert werden, wie Authentifizierung geprüft wird und wie Prisma innerhalb eines Route Handlers genutzt wird.
Die eigentliche DevNotes-App — das Erstellen, Bearbeiten und Löschen von Notizen über die Benutzeroberfläche — läuft jedoch vollständig über Server Actions (in lib/actions/notes.ts). Das ist absichtlich so: Server Actions sind für die Browser-UI die bessere Wahl, weil sie direkt in Formulare und React-Komponenten integriert werden können, ohne dass ihr fetch-Boilerplate schreiben müsst.
Wenn ihr das Capstone zu einer "richtigen" API erweitern wollt — etwa weil eine Handy-App die Notizen synchronisieren soll — wäre app/api/notes/route.ts der richtige Ausgangspunkt. Ihr könntet dort zusätzlich PUT /api/notes/[id] und DELETE /api/notes/[id] implementieren, eine Token-basierte Authentifizierung ergänzen und so eine vollständige REST-API aufbauen, ohne die bestehenden Server Actions anfassen zu müssen. Beide Wege koexistieren problemlos.