Route Handlers
Create a route.ts file in any app/ segment to define HTTP method handlers. Unlike old API routes, they use the standard Request and Response objects, making them portable to other runtimes.
Route Handlers (app/api)
A production-grade web application must expose HTTP endpoints to communicate with mobile apps, execute cron-jobs, or handle webhook notifications. In the Next.js App Router, these endpoints are built using Route Handlers. Unlike old Pages Router API routes that relied on Node.js-specific req and res objects, Route Handlers use the standard Web Request and Response APIs, making them extremely portable and compatible with Edge runtimes. In this lesson, we will cover how to structure dynamic route parameters, handle standard HTTP verbs, configure endpoint caching, and resolve CORS constraints.
1. Defining Route Handler File Structures
Route Handlers are defined by placing a file named route.ts (or .js) inside any folder inside your app/ directory. For example, a route at /api/users corresponds to app/api/users/route.ts.
CRITICAL RULE: You cannot place a route.ts and a page.tsx in the exact same folder segment (e.g. app/dashboard/route.ts and app/dashboard/page.tsx), as this causes routing collision conflicts. Keep APIs segregated inside an api/ subdirectory.
2. Handling GET and POST Request Formats
You map HTTP methods by exporting named functions from your route file. Here is how to implement a basic API that handles both retrieval and creation actions:
// app/api/users/route.ts
import { NextResponse } from 'next/server';
const mockUsers = [
{ id: 1, name: 'Qasim Ali' },
{ id: 2, name: 'Alex Developer' },
];
export async function GET() {
// Returns a standard application/json response automatically
return NextResponse.json(mockUsers);
}
export async function POST(request: Request) {
try {
const body = await request.json();
if (!body.name) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
}
const newUser = {
id: mockUsers.length + 1,
name: body.name,
};
mockUsers.push(newUser);
return NextResponse.json(newUser, { status: 201 });
} catch (error) {
return NextResponse.json({ error: 'Invalid JSON payload' }, { status: 400 });
}
}
3. Parsing Search Parameters and Query Strings
To inspect query strings (like /api/users?limit=5&sort=asc), extract parameters from the incoming request's nextUrl property:
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const limit = searchParams.get('limit') || '10';
const sort = searchParams.get('sort') || 'asc';
return NextResponse.json({
limit,
sort,
message: 'Parsed search parameters successfully.',
});
}
4. Accessing Dynamic Path Parameters (params)
For dynamic endpoints (e.g. /api/users/[id]), Next.js passes dynamic segments inside the second argument context.
Next.js 15/16 Convention: Just like Server Components, the params object inside Route Handlers must be awaited asynchronously:
// app/api/users/[id]/route.ts
import { NextResponse } from 'next/server';
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
// Await params object asynchronously
const { id } = await params;
return NextResponse.json({
userId: id,
message: 'Fetched dynamic profile path parameters.',
});
}
5. Controlling Route Handler Caching Behavior
Next.js caches GET Route Handlers by default during build compilation. If your API endpoint queries a database or external resource, it will serve static JSON results unless configured otherwise.
An API endpoint is evaluated dynamically (bypassing static cache) if it:
- Uses HTTP methods other than
GET(likePOST,PUT,DELETE). - Accesses request cookies (
cookies()) or headers (headers()). - Inspects search parameters on a
NextRequestobject.
To force an endpoint to execute dynamically on every request, export a configuration flag:
// app/api/status/route.ts
import { NextResponse } from 'next/server';
// Forces this endpoint to run on-demand, bypassing build-time caching
export const dynamic = 'force-dynamic';
export async function GET() {
return NextResponse.json({
timestamp: new Date().toISOString(),
});
}
6. Managing CORS Headers and Preflight OPTIONS Requests
If your API is queried from external domains, browser security models will block requests unless your endpoint returns Cross-Origin Resource Sharing (CORS) headers.
To permit external sites, configure custom headers and support the preflight OPTIONS verb:
// app/api/public-data/route.ts
import { NextResponse } from 'next/server';
const corsHeaders = {
'Access-Control-Allow-Origin': '*', // Permit all domains (use specific URL in production)
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
// Handle browser preflight checks
export async function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: corsHeaders,
});
}
export async function GET() {
return NextResponse.json(
{ message: 'CORS-enabled public endpoint' },
{ headers: corsHeaders }
);
}
7. Setting Up Custom Status Codes and Headers
Use the NextResponse constructor options to set HTTP status codes and attach custom headers (like caching headers) to control browser or CDN caching:
// app/api/nocache/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
return new NextResponse(JSON.stringify({ status: 'ok' }), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store, max-age=0, must-revalidate',
},
});
}
8. Streaming Responses dynamically
Because Route Handlers are built on standard Web APIs, you can stream content to the client (for instance, rendering incremental AI generations or loading huge database streams line-by-line) using a ReadableStream:
// app/api/stream/route.ts
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
controller.enqueue(encoder.encode('Starting stream...\n'));
await new Promise((r) => setTimeout(r, 1000));
controller.enqueue(encoder.encode('Processing data chunk...\n'));
await new Promise((r) => setTimeout(r, 1000));
controller.enqueue(encoder.encode('Done!\n'));
controller.close();
},
});
return new Response(stream, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
9. Common Gotchas
-
GET Request Static Caching Bugs: During production build compiling, Next.js caches simple
GETRoute Handlers. If your database updates in production, the endpoint will continue to return old cached data unless you setexport const dynamic = 'force-dynamic'or configure proper revalidation intervals. -
Path Conflicts in File Routing: Creating both
app/courses/page.tsxandapp/courses/route.tswill crash your build pipeline. segmento paths must lead to one source: a Page or a Route Handler, never both. -
Accessing Request Body in GET Requests: Standard Web protocols disallow
GETrequests from carrying payload bodies. Attempting to runawait request.json()inside a GET handler will throw a runtime parser error.
Key Takeaways
- Route Handlers are defined in
route.tsfiles and use standard Web Request and Response APIs. - Segregate endpoints inside
app/api/directories to avoid page routing collisions. - Always await dynamic route parameters (
params) asynchronously in Next.js 15/16. - GET handlers are statically compiled by default; use
force-dynamicto enforce dynamic execution. - Expose preflight
OPTIONSendpoints to permit cross-origin requests (CORS).
Exposing route endpoints allows external systems to interact with your data. However, processing arbitrary client data opens security holes. In the next lesson, we will cover Building a REST API with Validation, learning how to validate incoming request bodies with Zod to secure database writes.