1. Introduction đ
Building an API used to mean reaching for a separate backend framework. With Route Handlers, Next.js lets you define fully-featured API endpoints directly inside the app directory â no extra server required. This tutorial walks through everything from your first GET handler to advanced topics like streaming, authentication, and CORS.
Information
2. What are Route Handlers? đ¤
A Route Handler is a special file, always named route.js or route.ts, that lets you define custom request handlers for a given route using the standard Web Request and Response APIs. They replace the old pages/api convention when working inside the app directory.
- Built on top of native Request and Response Web APIs.
- Co-located with your UI routes inside app.
- Support both the Node.js and Edge runtimes.
- Can be cached or dynamic depending on the HTTP method and configuration.
3. Route Handler File Structure đ
Route Handlers live inside a special route.js file within any folder of the app directory. A folder cannot have both a page.js and a route.js at the same URL segment.
Warning
4. HTTP Methods đ
A single route.ts file can export a function for each HTTP method it supports. Next.js automatically maps the exported function name to the corresponding verb.
| Export | HTTP Verb | Typical Use |
|---|---|---|
| GET | GET | Fetch data |
| POST | POST | Create a resource |
| PUT | PUT | Replace a resource |
| PATCH | PATCH | Partially update a resource |
| DELETE | DELETE | Remove a resource |
| HEAD | HEAD | Fetch headers only |
| OPTIONS | OPTIONS | CORS preflight |
5. GET Requests
The GET export is used to read data. By default, GET handlers using the fetch API are cached unless you opt out.
app/api/users/route.ts
export async function GET() {
const res = await fetch('https://api.example.com/users');
const users = await res.json();
return Response.json(users);
}6. POST Requests
Use POST to create new resources. Reading the incoming Request body is done with await request.json().
app/api/users/route.ts
export async function POST(request: Request) {
const body = await request.json();
const newUser = await db.users.create(body);
return Response.json(newUser, { status: 201 });
}7. PUT Requests
PUT is conventionally used to fully replace an existing resource with a new representation.
app/api/users/[id]/route.ts
export async function PUT(request: Request, { params }: { params: { id: string } }) {
const body = await request.json();
const updated = await db.users.replace(params.id, body);
return Response.json(updated);
}8. PATCH Requests
PATCH updates only the fields provided, rather than replacing the whole object like PUT.
app/api/users/[id]/route.ts
export async function PATCH(request: Request, { params }: { params: { id: string } }) {
const partial = await request.json();
const updated = await db.users.update(params.id, partial);
return Response.json(updated);
}9. DELETE Requests
The DELETE export removes a resource, typically identified by a dynamic segment.
app/api/users/[id]/route.ts
export async function DELETE(request: Request, { params }: { params: { id: string } }) {
await db.users.delete(params.id);
return new Response(null, { status: 204 });
}10. Request Object đĨ
Every handler receives a standard, extended NextRequest object (a superset of the Web Request). It gives you access to headers, cookies, and the URL, in addition to the usual body-reading methods like .json(), .text(), and .formData().
app/api/example/route.ts
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
console.log(request.url);
console.log(request.method);
return Response.json({ ok: true });
}11. Response Object đ¤
Handlers return a standard Web Response object. Next.js also provides NextResponse, a convenience wrapper with helpers for redirects, rewrites, and cookies.
app/api/example/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({ message: 'Hello!' }, { status: 200 });
}12. URL Parameters đ§Š
Dynamic segments like [id] in the folder path are passed to your handler through the second params argument.
app/api/posts/[slug]/route.ts
export async function GET(request: Request, { params }: { params: { slug: string } }) {
return Response.json({ slug: params.slug });
}13. Query Parameters đ
Query strings are read from the request's URL via nextUrl.searchParams (or by constructing a URL from request.url).
app/api/search/route.ts
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
const query = request.nextUrl.searchParams.get('q');
return Response.json({ query });
}14. Request Headers đ
Use the headers() function from next/headers, or read directly from request.headers, to inspect incoming header values.
app/api/example/route.ts
import { headers } from 'next/headers';
export async function GET() {
const headersList = headers();
const auth = headersList.get('authorization');
return Response.json({ auth });
}15. Response Headers đˇī¸
Set custom headers using the headers option on the Response constructor.
app/api/example/route.ts
export async function GET() {
return new Response(JSON.stringify({ ok: true }), {
headers: { 'Content-Type': 'application/json', 'X-Custom-Header': 'value' },
});
}16. Cookies đĒ
The cookies() function from next/headers lets you read and set cookies directly from a Route Handler.
app/api/session/route.ts
import { cookies } from 'next/headers';
export async function GET() {
const cookieStore = cookies();
const theme = cookieStore.get('theme');
cookieStore.set('lastVisit', Date.now().toString());
return Response.json({ theme });
}17. Reading Request Body đĻ
The body can be parsed as JSON, plain text, FormData, or an ArrayBuffer, depending on the incoming Content-Type.
| Method | Use Case |
|---|---|
| request.json() | Parsing JSON payloads |
| request.text() | Reading raw text/plain bodies |
| request.formData() | Handling form submissions & file uploads |
| request.arrayBuffer() | Handling binary data |
18. Returning JSON đ§ž
The simplest way to return JSON is Response.json() (or NextResponse.json()), which automatically sets the correct Content-Type header.
app/api/example/route.ts
export async function GET() {
return Response.json({ success: true, data: [1, 2, 3] });
}19. Returning Files đī¸
To return binary content â such as images or PDFs â construct a Response with an ArrayBuffer or Blob and set the correct Content-Type.
app/api/download/route.ts
import fs from 'node:fs/promises';
export async function GET() {
const file = await fs.readFile('./public/report.pdf');
return new Response(file, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename="report.pdf"',
},
});
}20. Streaming Responses đ
Route Handlers support the native ReadableStream API, making it possible to stream chunks of data â ideal for AI completions or large exports.
app/api/stream/route.ts
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 5; i++) {
controller.enqueue(encoder.encode(`chunk ${i}\n`));
await new Promise((r) => setTimeout(r, 500));
}
controller.close();
},
});
return new Response(stream);
}21. Error Handling â ī¸
Wrap your logic in try/catch blocks and return appropriate status codes. Uncaught errors will result in a generic 500 response.
app/api/users/route.ts
export async function GET() {
try {
const users = await db.users.findAll();
return Response.json(users);
} catch (error) {
return Response.json({ error: 'Failed to fetch users' }, { status: 500 });
}
}Best Practice
22. Authentication đ
Authentication verifies who the caller is â typically via a session cookie or a bearer token in the Authorization header.
app/api/profile/route.ts
import { cookies } from 'next/headers';
export async function GET() {
const token = cookies().get('session')?.value;
if (!token) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await verifySession(token);
return Response.json(user);
}23. Authorization đĄī¸
Authorization checks what an authenticated user is allowed to do. This usually runs after authentication succeeds.
app/api/admin/route.ts
export async function DELETE(request: Request) {
const user = await getCurrentUser(request);
if (user.role !== 'admin') {
return Response.json({ error: 'Forbidden' }, { status: 403 });
}
// proceed with admin-only action
return Response.json({ success: true });
}24. File Uploads đ¸
File uploads are typically handled by reading multipart/form-data through request.formData().
app/api/upload/route.ts
export async function POST(request: Request) {
const formData = await request.formData();
const file = formData.get('file') as File;
const bytes = await file.arrayBuffer();
await saveToStorage(file.name, Buffer.from(bytes));
return Response.json({ uploaded: file.name });
}25. Form Data đ
Standard HTML form submissions (application/x-www-form-urlencoded) are also read with formData().
app/api/contact/route.ts
export async function POST(request: Request) {
const formData = await request.formData();
const email = formData.get('email');
const message = formData.get('message');
await sendEmail(email, message);
return Response.json({ sent: true });
}26. Webhooks đ
Webhooks are inbound POST requests from third-party services (e.g. Stripe, GitHub). Always verify the signature before trusting the payload.
app/api/webhooks/stripe/route.ts
export async function POST(request: Request) {
const signature = request.headers.get('stripe-signature');
const body = await request.text();
const event = verifyStripeSignature(body, signature);
await handleStripeEvent(event);
return new Response(null, { status: 200 });
}Caution
27. CORS đ
CORS headers control which origins may call your API from the browser. Handle preflight requests with an OPTIONS export.
app/api/public/route.ts
export async function GET() {
return Response.json({ data: 'ok' }, {
headers: { 'Access-Control-Allow-Origin': 'https://example.com' },
});
}
export async function OPTIONS() {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': 'https://example.com',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
},
});
}28. Route Configuration âī¸
Route segment config options let you control caching and runtime behavior by exporting special constants.
| Export | Purpose |
|---|---|
| dynamic | Force static or dynamic rendering |
| revalidate | Time-based cache revalidation |
| runtime | Choose "nodejs" or "edge" |
app/api/data/route.ts
export const dynamic = 'force-dynamic';
export const revalidate = 60;29. Edge Runtime âĄ
The Edge Runtime runs on a lightweight V8 isolate, close to the user, with a limited Node.js API surface â ideal for low-latency, stateless handlers.
app/api/edge/route.ts
export const runtime = 'edge';
export async function GET() {
return Response.json({ region: process.env.VERCEL_REGION });
}30. Node.js Runtime đĨī¸
The default Node.js runtime gives full access to the Node.js APIs â file system, native modules, and long-running processes â at the cost of slightly higher cold-start latency.
app/api/node/route.ts
export const runtime = 'nodejs';
import fs from 'node:fs/promises';
export async function GET() {
const contents = await fs.readFile('./data.json', 'utf-8');
return Response.json(JSON.parse(contents));
}31. Performance Optimization đī¸
- Cache GET responses whenever the data doesn't change per-request.
- Prefer the Edge Runtime for simple, latency-sensitive handlers.
- Use revalidate instead of force-dynamic where possible.
- Stream large payloads rather than buffering them entirely in memory.
- Batch database calls to avoid redundant round-trips.
32. Security Best Practices đ
- Always validate and sanitize incoming request bodies.
- Rate-limit public endpoints to prevent abuse.
- Verify webhook signatures before processing payloads.
- Never expose internal error details or stack traces.
- Restrict CORS origins to trusted domains only.
Danger
33. Common Mistakes đĢ
34. Frequently Asked Questions â
Yes â middleware.ts runs before your Route Handler and can rewrite, redirect, or short-circuit the request.
GET handlers are cached by default when using static rendering; all other methods (POST, PUT, PATCH, DELETE) are never cached.
Yes â simply set the Content-Type header to text/html and return the markup as a string in the response body.