One of the most consequential decisions in full-stack web development is where the boundary between frontend and backend sits — and how the two sides communicate. The traditional answer is a separate backend service: a Node.js or Express server running independently, exposing an API that the frontend calls. This works, but it means two codebases, two deployments, and the overhead of maintaining both.
Next.js offers a different answer. Route Handlers are API endpoints defined inside the Next.js project itself, living alongside the frontend code, deployed as part of the same application. The backend and the frontend share a single codebase, a single repository, and a single deployment pipeline. For many applications — particularly those built by small teams or solo developers — this is a significantly more productive architecture.
What Route Handlers Are
A Route Handler is a file named route.ts (or route.js) placed inside the app/ directory. It exports named functions corresponding to HTTP methods — GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS — and each function receives an HTTP request and returns an HTTP response.
// app/api/users/route.ts
export async function GET() {
const users = [
{ id: 1, name: 'Wariz' },
{ id: 2, name: 'Amina' },
];
return Response.json(users);
}
Visiting /api/users — or making a GET request to that URL from any client — returns a JSON response containing the users array. That is a complete API endpoint. No Express. No separate server. No additional configuration.
Route Handlers use the standard Web Request and Response APIs — the same APIs used in the browser and in modern edge runtimes. This is a deliberate design choice: Next.js Route Handlers are not Node.js-specific constructs. They speak the same language as the web platform itself.
File Location and URL Mapping
Route Handlers follow the same file-system routing convention as pages, with one important rule: a route.ts file and a page.tsx file cannot exist in the same folder. A folder is either a UI route or an API endpoint — not both.
app/
├── api/
│ ├── users/
│ │ └── route.ts → /api/users
│ └── posts/
│ └── route.ts → /api/posts
└── users/
└── page.tsx → /users (UI route — no route.ts here)
Placing API routes under an api/ subfolder is a common convention but not a requirement. A Route Handler at app/webhook/route.ts is equally valid and maps to /webhook. The api/ prefix is organisational, not functional.
Handling Multiple HTTP Methods
A single route.ts file handles all the HTTP methods for that endpoint. Each method is its own exported function.
// app/api/users/route.ts
export async function GET() {
const users = await db.user.findMany();
return Response.json(users);
}
export async function POST(request: Request) {
const body = await request.json();
const user = await db.user.create({
data: {
name: body.name,
email: body.email,
},
});
return Response.json(user, { status: 201 });
}
A GET request to /api/users returns all users. A POST request to the same URL creates a new user. The HTTP method determines which function runs — no routing logic required.
The Request Object
The request parameter received by each handler is a standard Web API Request object. It provides access to everything about the incoming request.
Reading the Request Body
export async function POST(request: Request) {
const body = await request.json();
// body is a parsed JavaScript object
}
request.json() parses the request body as JSON. This requires the client to send a Content-Type: application/json header alongside the body. For other body formats:
const text = await request.text(); // raw string
const formData = await request.formData(); // form submissions
const blob = await request.blob(); // binary data
Reading Headers
export async function GET(request: Request) {
const authHeader = request.headers.get('Authorization');
if (!authHeader) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
}
Reading Query Parameters
Query parameters are part of the URL and are accessed through the URL object:
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const page = searchParams.get('page') ?? '1';
const limit = searchParams.get('limit') ?? '10';
const users = await db.user.findMany({
skip: (Number(page) - 1) * Number(limit),
take: Number(limit),
});
return Response.json(users);
}
A request to /api/users?page=2&limit=20 returns the second page of users, twenty per page.
Dynamic Route Handlers
Route Handlers support the same dynamic segment conventions as pages. A route.ts file inside a [id] folder captures the URL parameter and receives it through a second argument.
app/
└── api/
└── users/
├── route.ts → /api/users
└── [id]/
└── route.ts → /api/users/42
// app/api/users/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const user = await db.user.findUnique({ where: { id: Number(id) } });
if (!user) {
return Response.json({ error: 'User not found' }, { status: 404 });
}
return Response.json(user);
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
const user = await db.user.update({
where: { id: Number(id) },
data: body,
});
return Response.json(user);
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
await db.user.delete({ where: { id: Number(id) } });
return new Response(null, { status: 204 });
}
This single file handles GET /api/users/42, PUT /api/users/42, and DELETE /api/users/42 — the complete set of operations for a single user resource. Together with the /api/users route handling GET (list) and POST (create), this is a fully RESTful user API with clean, readable code and no routing library.
Building the Response
Route Handlers return standard Web API Response objects. Next.js extends this with Response.json() as a convenient shorthand.
JSON Responses
// Shorthand
return Response.json({ message: 'Created' }, { status: 201 });
// Equivalent longhand
return new Response(JSON.stringify({ message: 'Created' }), {
status: 201,
headers: { 'Content-Type': 'application/json' },
});
Setting Status Codes
return Response.json({ error: 'Not found' }, { status: 404 });
return Response.json({ error: 'Unauthorized' }, { status: 401 });
return Response.json({ error: 'Bad request' }, { status: 400 });
return new Response(null, { status: 204 }); // No content
Setting Custom Headers
return new Response(JSON.stringify(data), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=3600',
'X-Custom-Header': 'value',
},
});
Error Handling
A well-designed API returns predictable, structured error responses rather than crashing or returning inconsistent output. The pattern is consistent across every handler: validate the input, handle failure cases explicitly, wrap the handler body in a try/catch for unexpected errors.
// app/api/users/route.ts
export async function POST(request: Request) {
try {
const body = await request.json();
if (!body.name || !body.email) {
return Response.json(
{ error: 'Name and email are required' },
{ status: 400 }
);
}
const existingUser = await db.user.findUnique({
where: { email: body.email },
});
if (existingUser) {
return Response.json(
{ error: 'A user with this email already exists' },
{ status: 409 }
);
}
const user = await db.user.create({
data: { name: body.name, email: body.email },
});
return Response.json(user, { status: 201 });
} catch (error) {
console.error('POST /api/users failed:', error);
return Response.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
The structure here — validate, check business rules, perform the operation, catch unexpected errors — is the same discipline established in the error handling topics of the Node.js and Express series, applied to the Next.js context.
CORS
When an API is called from a different origin — a separate frontend domain, a mobile client, a third-party integration — the browser enforces Cross-Origin Resource Sharing (CORS) rules. A Route Handler that needs to be accessible cross-origin must include the appropriate CORS headers.
export async function GET(request: Request) {
const data = await fetchData();
return new Response(JSON.stringify(data), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}
// Handle preflight requests
export async function OPTIONS() {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}
For APIs consumed only by the same Next.js application's own frontend — which is the most common case — CORS headers are not needed, because the frontend and backend share the same origin.
Cookies and Sessions
Route Handlers can read and set cookies using the cookies function from next/headers:
import { cookies } from 'next/headers';
export async function GET() {
const cookieStore = await cookies();
const token = cookieStore.get('auth-token');
if (!token) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
return Response.json({ authenticated: true });
}
export async function POST(request: Request) {
// After successful login
const cookieStore = await cookies();
cookieStore.set('auth-token', 'value', {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
});
return Response.json({ success: true });
}
The httpOnly flag prevents client-side JavaScript from reading the cookie — critical for authentication tokens. secure ensures the cookie is only sent over HTTPS. sameSite: 'lax' provides CSRF protection. These are the correct defaults for any cookie storing authentication state.
A Complete REST API Example
Bringing the patterns from this topic together, the following shows a complete, production-shaped API for a posts resource:
app/api/
├── posts/
│ ├── route.ts → GET /api/posts, POST /api/posts
│ └── [id]/
│ └── route.ts → GET /api/posts/:id, PUT /api/posts/:id, DELETE /api/posts/:id
// app/api/posts/route.ts
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const page = Number(searchParams.get('page') ?? '1');
const posts = await db.post.findMany({
skip: (page - 1) * 10,
take: 10,
orderBy: { createdAt: 'desc' },
});
return Response.json(posts);
} catch (error) {
return Response.json({ error: 'Failed to fetch posts' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
if (!body.title || !body.content) {
return Response.json(
{ error: 'Title and content are required' },
{ status: 400 }
);
}
const post = await db.post.create({
data: { title: body.title, content: body.content },
});
return Response.json(post, { status: 201 });
} catch (error) {
return Response.json({ error: 'Failed to create post' }, { status: 500 });
}
}
// app/api/posts/[id]/route.ts
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const post = await db.post.findUnique({ where: { id: Number(id) } });
if (!post) {
return Response.json({ error: 'Post not found' }, { status: 404 });
}
return Response.json(post);
} catch (error) {
return Response.json({ error: 'Failed to fetch post' }, { status: 500 });
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
const post = await db.post.update({
where: { id: Number(id) },
data: { title: body.title, content: body.content },
});
return Response.json(post);
} catch (error) {
return Response.json({ error: 'Failed to update post' }, { status: 500 });
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
await db.post.delete({ where: { id: Number(id) } });
return new Response(null, { status: 204 });
} catch (error) {
return Response.json({ error: 'Failed to delete post' }, { status: 500 });
}
}
Summary
| Concept | Details |
|---|---|
| File name | route.ts or route.js |
| HTTP methods | Export named functions: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS |
| URL mapping | Follows the same file-system convention as pages |
| Cannot coexist with | page.tsx in the same folder |
| Request body | await request.json(), .text(), .formData(), .blob() |
| Query parameters | new URL(request.url).searchParams |
| Dynamic segments | Second argument: { params: Promise<{ id: string }> } |
| Response | Response.json(data, { status }) or new Response(body, options) |
| Cookies | cookies() from next/headers |