Next.js API Routes (Route Handlers): Build Backend Endpoints in Next.js 16
Learn to build API endpoints in Next.js 16 with Route Handlers: GET/POST/PUT/DELETE, dynamic routes, validation with zod, authentication, error handling, CORS, and when to use Server Actions instead.
On this page (13)
In the Next.js App Router, API endpoints are called Route Handlers. You create a route.ts file inside the app directory and export functions named after HTTP methods (GET, POST, PUT, PATCH, DELETE). They use the standard Web Request and Response APIs, run on the server, and let you build a full-stack app without a separate backend.
New to Next.js? Start with Getting Started with Next.js and the routing guide.
When to use Route Handlers (and when not to)
Use Route Handlers for:
- Webhooks from services like Stripe or GitHub
- Public APIs consumed by mobile apps or third parties
- Endpoints called from client-side code
- Proxying requests that need a secret API key
For forms and data mutations inside your own app, Server Actions are often simpler: you call a server function directly from a form without writing an endpoint. AI agents frequently create API routes where a Server Action would be cleaner, so it's worth asking yours which approach it's using and why.
Your first Route Handler
// app/api/hello/route.ts
export async function GET() {
return Response.json({ message: 'Hello from Next.js!' });
}
Visit /api/hello and you'll get JSON back. Note: a folder can contain either a page or a route file, not both.
Handling multiple methods
// app/api/todos/route.ts
type Todo = { id: string; title: string; done: boolean };
export async function GET() {
const todos: Todo[] = await db.todo.findMany();
return Response.json({ todos });
}
export async function POST(request: Request) {
const body = await request.json();
const todo = await db.todo.create({ data: { title: body.title, done: false } });
return Response.json({ todo }, { status: 201 });
}
GET handlers are not cached by default (this changed in Next.js 15). If you want a static response, add export const dynamic = 'force-static', which is how this site generates its RSS feed.
Reading request data
import { type NextRequest } from 'next/server';
import { cookies, headers } from 'next/headers';
export async function GET(request: NextRequest) {
// Query string: /api/search?q=nextjs
const q = request.nextUrl.searchParams.get('q');
// Headers and cookies (both async in Next.js 16)
const auth = (await headers()).get('authorization');
const theme = (await cookies()).get('theme')?.value;
return Response.json({ q, hasAuth: Boolean(auth), theme });
}
Dynamic API routes
// app/api/todos/[id]/route.ts
export async function GET(request: Request, ctx: RouteContext<'/api/todos/[id]'>) {
const { id } = await ctx.params;
const todo = await db.todo.findUnique({ where: { id } });
if (!todo) {
return Response.json({ error: 'Not found' }, { status: 404 });
}
return Response.json({ todo });
}
export async function DELETE(request: Request, ctx: RouteContext<'/api/todos/[id]'>) {
const { id } = await ctx.params;
await db.todo.delete({ where: { id } });
return new Response(null, { status: 204 });
}
As with pages, params is a Promise in Next.js 16. RouteContext is a generated type helper.
Validate every input
Never trust request bodies. AI-generated endpoints often skip validation, which leads to crashes and security bugs. Use a schema library like zod (opens in a new tab):
import { z } from 'zod';
const CreateTodo = z.object({
title: z.string().trim().min(1).max(200),
});
export async function POST(request: Request) {
const parsed = CreateTodo.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return Response.json({ error: parsed.error.flatten() }, { status: 400 });
}
const todo = await db.todo.create({ data: { title: parsed.data.title, done: false } });
return Response.json({ todo }, { status: 201 });
}
Authentication and authorization
Check who is calling and whether they're allowed in every handler that touches private data, not just in proxy/middleware:
import { auth } from '@/lib/auth'; // your auth library (Auth.js, Clerk, Supabase, etc.)
export async function DELETE(request: Request, ctx: RouteContext<'/api/todos/[id]'>) {
const session = await auth();
if (!session) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await ctx.params;
const todo = await db.todo.findUnique({ where: { id } });
// Authorization: users may only delete their own todos
if (!todo || todo.userId !== session.user.id) {
return Response.json({ error: 'Not found' }, { status: 404 });
}
await db.todo.delete({ where: { id } });
return new Response(null, { status: 204 });
}
Missing ownership checks like this are one of the most common vulnerabilities in vibe-coded apps. Our security checklist covers the rest.
Error handling
export async function GET() {
try {
const data = await fetchSomething();
return Response.json({ data });
} catch (error) {
console.error('GET /api/something failed', error);
// Don't leak internal error details to clients
return Response.json({ error: 'Internal server error' }, { status: 500 });
}
}
CORS
Only add CORS headers if other domains genuinely need to call your API, and avoid * for endpoints that use cookies or return private data:
const ALLOWED_ORIGIN = 'https://app.example.com';
export async function OPTIONS() {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
'Access-Control-Allow-Methods': 'GET, POST',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}
Webhooks: verify signatures
For webhooks, read the raw body and verify the provider's signature before trusting the payload:
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return Response.json({ error: 'Invalid signature' }, { status: 400 });
}
// handle event.type ...
return Response.json({ received: true });
}
Testing your API
- curl:
curl -X POST localhost:3000/api/todos -H "Content-Type: application/json" -d '{"title":"Test"}' - REST clients: Bruno, Postman or Thunder Client
- Automated tests: ask your AI agent to write integration tests for each endpoint, including unauthorized and invalid-input cases
Best practices
- Validate all input with a schema.
- Check authentication and authorization in every handler.
- Keep secrets in environment variables, never in client code.
- Return consistent JSON shapes and correct status codes.
- Rate-limit public endpoints (use a shared store like Redis, not in-memory counters, on serverless hosts).
- Prefer Server Actions for your own forms; use Route Handlers for external callers.
Next steps
- Protect pages with Next.js proxy (middleware)
- Build a complete app with our full-stack AI walkthrough
- Read the official Route Handlers docs (opens in a new tab)
- #Next.js
- #API
- #Backend
- #Tutorial