Published Sep 16, 2025 ⦁ Updated Oct 11, 2026 ⦁ 15 min read
Next.js API Routes: Route Handlers in the App Router

Next.js API Routes: Route Handlers in the App Router

In the Next.js App Router, API routes are called Route Handlers. You create a route.ts file anywhere under app/ (usually app/api/...) and export a function named after each HTTP method, such as GET or POST. Each function takes a Web Request and returns a Web Response. The older pages/api API Routes still work in the Pages Router, but new App Router projects should use Route Handlers.

Everything below matches the Next.js 16 documentation (version 16.4 as of October 2026). The main references are the Route Handlers guide and the route.js API reference.

App Router Route Handlers vs Pages Router API Routes

App Router: Route Handlers Pages Router: API Routes
File app/api/users/route.ts pages/api/users.ts
Handler One named export per method: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS One default export; branch on req.method
Request/response Web Request / Response (plus NextRequest / NextResponse) Node req / res with Express-style helpers
Body parsing await request.json(), .formData(), .text() Automatic bodyParser (1 MB default limit)
Unsupported method Automatic 405 Method Not Allowed You handle it yourself
Static export GET only, with dynamic = 'force-static' Not supported

You don't need both. The docs say Route Handlers are the App Router equivalent of API Routes, and the Pages Router API Routes page recommends Route Handlers for streaming on Next.js 14 and later.

Create your first Next.js API route

  1. Create a project with an example endpoint already in it: npx create-next-app@latest --api (or pnpm create next-app --api).
  2. Or add a file by hand at app/api/hello/route.ts:
// app/api/hello/route.ts
export async function GET() {
  return Response.json({ message: 'Hello from Next.js' })
}
  1. Run npm run dev and open http://localhost:3000/api/hello. You get {"message":"Hello from Next.js"}.

The URL comes from the folder path, and route.ts is the endpoint. One rule trips people up: a route.ts and a page.tsx cannot share the same segment. app/page.tsx plus app/route.ts is a conflict, but app/page.tsx plus app/api/route.ts is fine.

Handle GET and POST requests

Export one function per method. Reading the body uses standard Web APIs, so there's no bodyParser config.

// app/api/todos/route.ts
import { type NextRequest } from 'next/server'

const todos = [{ id: 1, title: 'Write docs' }]

export async function GET(request: NextRequest) {
  const q = request.nextUrl.searchParams.get('q') ?? ''
  return Response.json(todos.filter(t => t.title.includes(q)))
}

export async function POST(request: Request) {
  const body = await request.json().catch(() => null)
  if (!body || typeof body.title !== 'string' || body.title.length > 200) {
    return Response.json({ error: 'title is required' }, { status: 400 })
  }
  const todo = { id: todos.length + 1, title: body.title }
  todos.push(todo)
  return Response.json(todo, { status: 201 })
}

Test it:

curl "http://localhost:3000/api/todos?q=docs"
curl -X POST http://localhost:3000/api/todos \
  -H "Content-Type: application/json" \
  -d '{"title":"Ship it"}'

The in-memory array is only for the demo. On serverless hosts each request can run in a fresh instance, so the docs warn that handlers "cannot share data between requests." Use a database.

A request body can be read only once. If you need it twice (for example, to verify a webhook signature and then parse it), call request.clone() first. For form posts use await request.formData(). All values arrive as strings.

Dynamic API routes and params

Put the parameter in square brackets. Since Next.js 15, params is a Promise, so you must await it:

// app/api/users/[id]/route.ts
export async function GET(
  _request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
  return Response.json({ id })
}

In TypeScript you can use the global RouteContext helper instead of writing the type by hand: ctx: RouteContext<'/api/users/[id]'>. The types are generated when you run next dev, next build or next typegen.

Catch-all segments work the same way. app/api/files/[...path]/route.ts gives you { path: ['a', 'b'] } for /api/files/a/b.

Headers, cookies, redirects and CORS

  • Read headers: request.headers.get('authorization'), or const h = await headers() from next/headers. The headers() result is read-only. To set headers, return them on the Response.
  • Cookies: request.cookies.get('token') reads one. const store = await cookies() from next/headers can also set and delete.
  • Redirect: NextResponse.redirect(new URL('/login', request.url)), or redirect() from next/navigation.
  • CORS: API routes are same-origin unless you add headers. If you don't export OPTIONS, Next.js answers preflight requests itself and sets the Allow header.
// app/api/public/route.ts
export async function GET() {
  return Response.json(
    { ok: true },
    {
      headers: {
        'Access-Control-Allow-Origin': 'https://example.com',
        'Access-Control-Allow-Methods': 'GET, OPTIONS',
      },
    }
  )
}

To apply CORS to many routes at once, use headers() in next.config.js or the proxy.ts file (Next.js 16 renamed middleware.ts to proxy.ts).

Caching: GET handlers are dynamic by default

Next.js 15 changed GET handlers from static to dynamic by default. If you want a cached GET, opt in:

// app/api/posts/route.ts
export const revalidate = 60 // or: export const dynamic = 'force-static'

export async function GET() {
  const res = await fetch('https://api.vercel.app/blog')
  return Response.json(await res.json())
}

POST, PUT, PATCH and DELETE are never cached, even if they sit in the same file as a cached GET. If you have enabled Cache Components, GET handlers follow the same rules as pages, and you cache a database call by moving it into a helper marked 'use cache'.

How to protect Next.js API routes

Every Route Handler is a public HTTP endpoint. Anyone can call it with curl, so hiding the URL in your frontend protects nothing. The backend-for-frontend guide recommends:

  1. Check auth inside the handler. You can use proxy.ts for an early 401, but the docs say not to rely on proxy alone. Verify the session or token in the handler itself, or in a data-access function it calls.
  2. Validate input (type, size, content type) before passing it to a database or another API. A schema library such as Zod helps.
  3. Rate limit in code, and turn on your host's firewall rate limits too. Return 429 when a client goes over.
  4. Don't leak internals. Return generic error messages, and never copy incoming request headers straight into the response.
  5. Use POST for sensitive data. GET URLs get logged and cached.
// app/api/me/route.ts
import { cookies } from 'next/headers'
import { getSession } from '@/lib/auth' // your auth library

export async function GET() {
  const session = await getSession((await cookies()).get('session')?.value)
  if (!session) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }
  return Response.json({ user: session.user })
}

If you need a refresher on status codes, see how to handle 400 Bad Request in REST APIs.

Route Handlers vs Server Actions

  • Use a Route Handler when something other than your own React UI calls the endpoint: a mobile app, a webhook (Stripe, GitHub), a cron job, a public API, an RSS or sitemap file, or a streaming response.
  • Use a Server Action for form submissions and mutations from your own components. You don't need to write a fetch call or define a URL.
  • Don't call your own Route Handlers from Server Components. The docs say to fetch from the data source directly. During a build there's no server to answer the request, and at runtime it adds an extra HTTP round trip.

Server Actions are queued, so the docs advise against using them to fetch data.

Limits to know before you deploy

  • On serverless hosts (Vercel, Netlify, AWS Lambda), Route Handlers run as functions. They have time limits, can't keep WebSockets open, and may not be able to write to the file system.
  • output: 'export' (a static site) supports only GET handlers marked force-static.
  • Pages Router API Routes warn when a response is larger than 4 MB (responseLimit) and limit request bodies to 1 MB by default (bodyParser.sizeLimit).

If you are choosing where to deploy, see our comparison of cloud hosting for developers and Vercel vs Netlify. For practice data to call from your routes, browse the free APIs directory.

FAQ

Does Next.js have an API router?

Yes. In the App Router, any route.ts file under app/ becomes an API endpoint (a Route Handler), and the folder path is the URL. In the Pages Router, files in pages/api map to /api/*. No separate router library is needed.

Where do I put API routes in the Next.js app folder?

By convention in app/api/<name>/route.ts, but any folder under app/ works as long as that segment has no page.tsx. app/api/users/route.ts serves /api/users.

Why is params a Promise in my route handler?

Next.js 15 made context.params asynchronous. Write const { id } = await params. Next.js provides a codemod that updates older code for you.

Is Next.js good enough to replace an Express backend?

For a backend that serves your own frontend (auth callbacks, webhooks, form handling, proxying other APIs), yes. The Next.js docs say it is "not a full backend replacement". For long-running jobs, WebSockets or heavy background work, run a separate service.

Why does my GET route return stale data?

In Next.js 15 and later, GET handlers are dynamic unless you opt in. If yours is cached, look for export const dynamic = 'force-static', a revalidate export, or a cached fetch or 'use cache' helper in the handler or the code it calls. On Next.js 14 and earlier, GET handlers were static by default.