Next.js App Router uses Route Handlers to create API endpoints. These are defined in route.ts files inside the app/ directory and give you full control over HTTP requests and responses.
Your First Route Handler
Create app/api/hello/route.ts:
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ message: "Hello from the API" });
}This responds to GET /api/hello with a JSON payload. Each exported function name corresponds to an HTTP method.
Handling All CRUD Methods
Here is a complete example for a /api/posts endpoint:
import { NextRequest, NextResponse } from "next/server";
type Post = {
id: string;
title: string;
content: string;
};
// In-memory store for demonstration
const posts: Post[] = [];
// GET /api/posts
export async function GET() {
return NextResponse.json(posts);
}
// POST /api/posts
export async function POST(request: NextRequest) {
const body = await request.json();
const post: Post = {
id: crypto.randomUUID(),
title: body.title,
content: body.content,
};
posts.push(post);
return NextResponse.json(post, { status: 201 });
}For routes that need a dynamic parameter like /api/posts/[id], create app/api/posts/[id]/route.ts:
import { NextRequest, NextResponse } from "next/server";
// GET /api/posts/:id
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const post = posts.find((p) => p.id === id);
if (!post) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(post);
}
// PUT /api/posts/:id
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
const index = posts.findIndex((p) => p.id === id);
if (index === -1) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
posts[index] = { ...posts[index], ...body };
return NextResponse.json(posts[index]);
}
// DELETE /api/posts/:id
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const index = posts.findIndex((p) => p.id === id);
if (index === -1) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
posts.splice(index, 1);
return new NextResponse(null, { status: 204 });
}Working with Request Data
Route handlers give you access to headers, cookies, query parameters, and the request body:
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
// Query parameters
const searchParams = request.nextUrl.searchParams;
const page = parseInt(searchParams.get("page") ?? "1");
const limit = parseInt(searchParams.get("limit") ?? "10");
// Headers
const authHeader = request.headers.get("authorization");
// Cookies
const token = request.cookies.get("session-token")?.value;
return NextResponse.json({
page,
limit,
authenticated: !!authHeader,
hasSession: !!token,
});
}Setting Response Headers and Cookies
export async function GET() {
const response = NextResponse.json({ status: "ok" });
// Custom headers
response.headers.set("X-Request-Id", crypto.randomUUID());
response.headers.set("Cache-Control", "s-maxage=60, stale-while-revalidate");
// Set a cookie
response.cookies.set("visited", "true", {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 60 * 60 * 24, // 1 day
});
return response;
}Middleware for Route Handlers
Create middleware.ts at the project root to run logic before route handlers execute:
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
// Rate limiting example: check a header
const apiKey = request.headers.get("x-api-key");
if (request.nextUrl.pathname.startsWith("/api/admin") && !apiKey) {
return NextResponse.json({ error: "API key required" }, { status: 401 });
}
// Add a custom header and continue
const response = NextResponse.next();
response.headers.set("X-Middleware", "active");
return response;
}
export const config = {
matcher: "/api/:path*",
};Error Handling Pattern
Wrap route handler logic in try/catch for clean error responses:
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const PostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const data = PostSchema.parse(body);
// ... create the post
return NextResponse.json(data, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "Validation failed", details: error.errors },
{ status: 400 }
);
}
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}Summary
- Route handlers live in
route.tsfiles and export functions named after HTTP methods. - Use
NextRequestto read query params, headers, cookies, and body. - Use
NextResponseto send JSON, set headers, and manage cookies. - Middleware runs before route handlers and is useful for auth checks and rate limiting.
- Always validate input and handle errors gracefully.