Route Handlers & APIs in Next.js

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

This guide assumes you're using the Next.js app router (Next.js 13+). Route Handlers do not exist in the legacy pages router — that uses API Routes instead.

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.

app
api
users
route.ts
layout.tsx
page.tsx

Warning

Placing a route.ts and page.tsx in the same segment will cause a build error. Choose one or the other per path.

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.

ExportHTTP VerbTypical Use
GETGETFetch data
POSTPOSTCreate a resource
PUTPUTReplace a resource
PATCHPATCHPartially update a resource
DELETEDELETERemove a resource
HEADHEADFetch headers only
OPTIONSOPTIONSCORS 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.

MethodUse 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

Never leak raw error objects or stack traces to the client — log them server-side and return a sanitized message instead.

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

Webhook endpoints must remain publicly reachable but should still validate a signing secret — never trust the payload blindly.

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.

ExportPurpose
dynamicForce static or dynamic rendering
revalidateTime-based cache revalidation
runtimeChoose "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

Storing secrets (API keys, tokens) directly in client-exposed code — even inside a Route Handler bundled for the Edge Runtime — can lead to accidental leakage. Keep secrets in environment variables.

33. Common Mistakes đŸšĢ

Common Route Handler Mistakes
Forgetting await when reading the request body
Mixing page.js and route.js in the same segment
Caching issues
Skipping input validation on user-submitted data
Not setting dynamic = 'force-dynamic' when needed
Assuming POST handlers are cached (they aren't, by default)

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.

35. Summary 📚