Next.js App Router

Route Handlers, Middleware (Proxy) and APIs

Writing API endpoints, request and response helpers, the request interception layer in this Next.js version, headers, redirects and rewrites.

Lesson 0414 min read#nextjs#route-handlers#middleware#api

On this page

  1. Words used in this lesson
  2. Route Handlers: the basics
  3. Are route handlers cached?
  4. Streaming a response
  5. Webhooks: trust, but verify
  6. CORS: a browser rule, not a firewall
  7. Security rules for route handlers
  8. Proxy (formerly middleware)
  9. Common mistakes
  10. Try it
  11. Interview questions
  12. Key takeaways
  13. What's next

In one line: A Route Handler is a route.ts file that answers HTTP requests with the standard Request and Response objects. It is how your Next.js app gets a public API: JSON endpoints, webhooks, file downloads, streams. Proxy (proxy.ts, the new name for middleware) is different. It is one file that runs before every matched request, and it can redirect, rewrite, add headers or answer on its own. Both are public entry points, so neither one replaces real authentication and validation inside your own code.

Server Components read data. Server Actions change data for your own forms. But sometimes the caller is not your page: a mobile app, a payment provider sending a webhook, another company's service, a curl in a script. These callers need a plain URL and plain HTTP. That is what route handlers give you. And sometimes you need to act on a request before any page or handler sees it, for example to send old URLs to new ones. That is what Proxy is for.

This is lesson 4 of ten. Lesson 3 taught Server Actions and showed that they are public POST endpoints in disguise. Here we build the "honest" version of that: endpoints that are meant to be public. The next lesson, Authentication and Authorization in Next.js, uses both Proxy and route handlers to protect things. For the general rules of good HTTP APIs (status codes, error shapes, pagination), see Request and Response Design and HTTP Methods, Status Codes and Idempotency in Practice.

What I ran / checked. Everything ran on Linux with Node.js v22.22.0, Next.js 16.3.5 (this repo's version, default caching model), Turbopack, React 19.3.0, TypeScript 7.0.2 and zod 4.6.6. I built a throwaway app with about a dozen route handlers and a proxy.ts, ran next build and next start, and tested with curl (status codes, headers, bodies, timings). Sources I read (in my own words below): inside node_modules/next/dist/docs/01-app/: "Route Handlers" and "Proxy" in getting started, the guide "Backend for frontend", and the API pages for route.js and proxy.js. Not verified: I did not open nextjs.org. I did not test cacheComponents route handlers, deployment to an edge network, file uploads, or an OAuth callback (I report the docs where I mention them). Timings are from one machine and will vary.

Words used in this lesson

  • Route Handler is a route.ts file in the app folder that exports functions named after HTTP methods (GET, POST, ...).
  • HTTP method (verb) says what you want to do: GET read, POST create, PUT/PATCH change, DELETE remove.
  • Status code is the number in the response: 200 OK, 201 Created, 204 No Content, 400/422 bad input, 401/403 not allowed, 404 not found, 405 method not allowed, 500 server error.
  • NextRequest / NextResponse are Next.js extensions of the standard Request and Response. They add helpers like request.nextUrl, request.cookies and NextResponse.redirect().
  • Webhook is a request that another service sends to your URL when something happens (for example, "payment succeeded").
  • HMAC signature is a hash made from the body and a shared secret. It proves the sender knows the secret and the body was not changed.
  • CORS (cross-origin resource sharing) is a browser rule that decides if a page from one website may read a response from another.
  • Preflight is the OPTIONS request a browser sends first to ask if a cross-origin request is allowed.
  • Proxy is the file proxy.ts. It runs before a request is completed. It was called middleware in older versions.
  • Matcher is the config.matcher setting that says which paths Proxy runs on.
  • Redirect tells the browser to go to another URL (the URL changes). Rewrite serves another route's content but keeps the URL the same.
  • Optimistic check is a quick, cheap check (like "is there a session cookie?") that is not real authorization.

Route Handlers: the basics

Create a file named route.ts anywhere inside app/. Export a function named after the HTTP method. The function gets a standard Request and returns a standard Response:

JavaScript
// app/api/hello/route.tsexport async function GET() {  return Response.json({ message: 'hello', builtAt: Date.now() })}

Response.json(...) is a web-standard helper. It sets the Content-Type and turns the object into a JSON string. The URL of this file is /api/hello. The folder structure works just like pages in lesson 0. There is one rule: a folder cannot have both a page and a route file (lesson 0 showed the build error).

I built it and asked for it:

TEXT
$ curl -i localhost:3501/api/helloHTTP/1.1 200 OKcontent-type: application/json{"message":"hello","builtAt":1791775533326}

Which methods are supported? GET, POST, PUT, PATCH, DELETE, HEAD and OPTIONS. The docs say that any method you do not export gets 405 Method Not Allowed, and I added a small test with a /api/users/[id] route that only exports GET and DELETE:

TEXT
$ curl -X PATCH localhost:3501/api/users/1HTTP/1.1 405 Method Not Allowed$ curl -I localhost:3501/api/users/1           (HEAD)HTTP/1.1 200 OKcontent-type: application/json$ curl -X OPTIONS localhost:3501/api/users/1HTTP/1.1 204 No Contentallow: DELETE, GET, HEAD, OPTIONS

HEAD and OPTIONS were filled in for me. OPTIONS even answered with an Allow header listing the real methods. Nice.

Dynamic routes and params

Dynamic segments work with brackets, as for pages. The second argument is a context with params, and params is a Promise (you know this from lesson 0). The global RouteContext type gives it the right shape:

JavaScript
// app/api/users/[id]/route.tsimport type { NextRequest } from 'next/server'const users: Record<string, { id: string; name: string }> = {  '1': { id: '1', name: 'Asha' },  '2': { id: '2', name: 'Ben' },}export async function GET(_req: NextRequest, ctx: RouteContext<'/api/users/[id]'>) {  const { id } = await ctx.params  const user = users[id]  if (!user) {    return Response.json({ error: { code: 'not_found', message: `No user ${id}` } }, { status: 404 })  }  return Response.json(user)}export async function DELETE(_req: NextRequest, ctx: RouteContext<'/api/users/[id]'>) {  const { id } = await ctx.params  return new Response(null, { status: 204, headers: { 'x-deleted': id } })}
TEXT
$ curl localhost:3501/api/users/1      -> 200 {"id":"1","name":"Asha"}$ curl localhost:3501/api/users/99     -> 404 {"error":{"code":"not_found","message":"No user 99"}}$ curl -X DELETE localhost:3501/api/users/2  -> 204 No Content   (x-deleted: 2)

Three habits to notice. The error has a stable shape (code plus message) that clients can rely on. A missing thing is 404, not 200 with an empty body. A successful delete is 204 with no body. The API design lessons explain why these matter.

Query strings, body and validation

For the query string, parse request.url, or use request.nextUrl.searchParams on a NextRequest. For a JSON body, call await request.json(). Never trust it. Here is a POST that validates with zod and returns the right status codes:

JavaScript
// app/api/items/route.tsimport { z } from 'zod'const items: { id: number; name: string; qty: number }[] = []const Body = z.object({ name: z.string().trim().min(1), qty: z.number().int().min(1).max(100) })export async function GET(request: Request) {  const { searchParams } = new URL(request.url)  const limit = Number(searchParams.get('limit') ?? 10)  return Response.json({ items: items.slice(0, limit), total: items.length })}export async function POST(request: Request) {  let json: unknown  try { json = await request.json() }  catch { return Response.json({ error: { code: 'bad_json', message: 'Body is not valid JSON' } }, { status: 400 }) }  const parsed = Body.safeParse(json)  if (!parsed.success) {    return Response.json({ error: { code: 'validation_failed', details: parsed.error.flatten().fieldErrors } }, { status: 422 })  }  const item = { id: items.length + 1, ...parsed.data }  items.push(item)  return Response.json(item, { status: 201, headers: { Location: `/api/items/${item.id}` } })}

Three requests, real output (I removed the unimportant headers):

TEXT
$ curl -i -X POST localhost:3501/api/items -H 'content-type: application/json' -d '{"name":"Pen","qty":3}'HTTP/1.1 201 Createdlocation: /api/items/1{"id":1,"name":"Pen","qty":3}$ curl -i -X POST ... -d '{"name":"","qty":0}'HTTP/1.1 422 Unprocessable Entity{"error":{"code":"validation_failed","details":{"name":["Too small: expected string to have >=1 characters"],"qty":["Too small: expected number to be >=1"]}}}$ curl -i -X POST ... -d 'not json'HTTP/1.1 400 Bad Request{"error":{"code":"bad_json","message":"Body is not valid JSON"}}

Unparseable JSON is 400. Valid JSON with wrong fields is 422. A new item is 201 with a Location header. (Some teams use 400 for both. Pick one and document it.) For form-style bodies use await request.formData().

The in-memory items array is only for the demo. It lives in one server process and disappears on restart. On serverless hosting, each instance would have its own copy. Real data goes in a database.

Cookies and headers

You can read cookies and headers with the helpers from next/headers (both are async now), or from the NextRequest. To set a cookie, set it on a NextResponse:

JavaScript
// app/api/me/route.tsimport { cookies, headers } from 'next/headers'import { NextResponse, type NextRequest } from 'next/server'export async function GET(request: NextRequest) {  const jar = await cookies()  const h = await headers()  return Response.json({    theme: jar.get('theme')?.value ?? null,    userAgent: h.get('user-agent')?.slice(0, 10),    auth: request.headers.get('authorization') ? 'present' : 'missing',    pathname: request.nextUrl.pathname,  })}export async function POST() {  const res = NextResponse.json({ ok: true })  res.cookies.set('theme', 'dark', { httpOnly: true, sameSite: 'lax', path: '/', maxAge: 3600 })  return res}
TEXT
$ curl -b 'theme=light' -H 'Authorization: Bearer x' localhost:3501/api/me{"theme":"light","userAgent":"curl/8.5.0","auth":"present","pathname":"/api/me"}$ curl -i -X POST localhost:3501/api/meset-cookie: theme=dark; Path=/; Expires=...; Max-Age=3600; HttpOnly; SameSite=lax

The cookie came back with HttpOnly (JavaScript on the page cannot read it) and SameSite=lax (the browser does not send it on most cross-site requests). Always set those two for anything that identifies a user. Passwords and Login explains why.

Do not copy request headers into a response. The docs warn about this: be deliberate about which headers go out. If you forward incoming headers wholesale, you may send private values (like an Authorization header) back to the client or to other parties.

Are route handlers cached?

In the default caching model, the docs say GET handlers are not cached by default. I checked the route table after next build:

TEXT
├ ƒ /api/hello├ ƒ /api/items├ ○ /api/static-time├ ○ /api/timed-time           3s      1y

/api/hello has no request-dependent code and no database, and it is still ƒ (dynamic). In lesson 2 a page with the same shape was ○ static. For route handlers, the safe default is to run on every request. I asked for /api/hello twice, one second apart, and builtAt differed (...533326 then ...534346).

To cache a GET on purpose, export route segment options. Two variants:

JavaScript
// app/api/static-time/route.tsexport const dynamic = 'force-static'export async function GET() { return Response.json({ at: new Date().toISOString() }) }
JavaScript
// app/api/timed-time/route.tsexport const revalidate = 3export async function GET() { return Response.json({ at: new Date().toISOString().slice(11, 23) }) }

static-time was built once. Three requests, one second apart, all returned "2026-10-12T03:25:47.096Z". The same moment from the build. timed-time refreshes after 3 seconds. I asked every second, and printed my clock next to the answer:

TEXT
request at :15.261 -> {"at":"03:26:04.603"}   <- old copy; this request starts a refreshrequest at :16.280 -> {"at":"03:26:15.273"}request at :17.299 -> {"at":"03:26:15.273"}request at :18.317 -> {"at":"03:26:15.273"}   <- older than 3 s now; refresh startsrequest at :19.336 -> {"at":"03:26:18.330"}request at :20.356 -> {"at":"03:26:18.330"}request at :21.374 -> {"at":"03:26:18.330"}request at :22.393 -> {"at":"03:26:21.388"}

This is the same stale-while-revalidate behaviour from lesson 2: a request that finds the copy older than 3 seconds still gets the old copy, and starts the refresh. Only GET can be cached. The docs say POST, PUT, DELETE and other methods are never cached, even next to a cached GET.

With cacheComponents: true (lesson 2), the docs say GET handlers follow the same prerendering model as pages: they run at request time by default, can be prerendered if they touch no uncached or runtime data, and can include cached data by calling a helper function marked with use cache. They note that use cache cannot be placed directly in the handler body, so move it to a helper function. I did not run this.

Streaming a response

A Response body can be a stream. This is useful for large downloads, server-sent events, or AI token streams. I made a handler that sends four chunks, 400 ms apart:

JavaScript
// app/api/stream/route.tsexport async function GET() {  const enc = new TextEncoder()  const stream = new ReadableStream({    async start(controller) {      for (let i = 1; i <= 4; i++) {        controller.enqueue(enc.encode(`chunk ${i} at ${new Date().toISOString().slice(17, 23)}\n`))        await new Promise(r => setTimeout(r, 400))      }      controller.close()    },  })  return new Response(stream, { headers: { 'content-type': 'text/plain; charset=utf-8' } })}

With curl -N (no buffering) and my own clock when each line arrived:

TEXT
05.672s arrived: chunk 1 at 05.66706.071s arrived: chunk 2 at 06.06806.472s arrived: chunk 3 at 06.46906.873s arrived: chunk 4 at 06.870

The lines arrived one by one, as they were made, not all at the end. The client does not wait for the whole body.

Webhooks: trust, but verify

A webhook is another service calling you. Because the URL is public, anyone can call it and claim to be Stripe, GitHub or your payment provider. The standard defence is a signature: the sender computes an HMAC of the raw body with a secret you both know, and sends it in a header. You compute it again and compare.

JavaScript
// app/api/webhook/route.tsimport { createHmac, timingSafeEqual } from 'node:crypto'const SECRET = 'whsec_demo_secret'            // in real life: process.env.WEBHOOK_SECRETexport async function POST(request: Request) {  const raw = await request.text()             // raw body: the signature covers the exact bytes  const given = request.headers.get('x-signature') ?? ''  const expected = createHmac('sha256', SECRET).update(raw).digest('hex')  const a = Buffer.from(given), b = Buffer.from(expected)  if (a.length !== b.length || !timingSafeEqual(a, b)) {    return Response.json({ error: 'bad signature' }, { status: 401 })  }  const event = JSON.parse(raw)  return Response.json({ received: event.type })}

Two details that matter. First, read the body as text (request.text()), and parse it only after the check. If you parse and re-serialise the JSON, the bytes may change and the signature will not match. Second, compare with timingSafeEqual, not ===, so an attacker cannot learn the signature one character at a time from response times.

I signed a body with openssl and sent three requests:

TEXT
good signature                         {"received":"invoice.paid"} [HTTP 200]wrong signature                        {"error":"bad signature"} [HTTP 401]body changed by 1 char, old signature  {"error":"bad signature"} [HTTP 401]

Providers also retry a webhook if you answer slowly or with an error, so your handler may see the same event twice. Make the handler idempotent, for example by storing event ids you already handled. Idempotency Keys, Retries, Webhooks and Rate Limits covers this pattern in detail. And answer fast. Do slow work after you reply, or in a queue.

CORS: a browser rule, not a firewall

CORS comes up when a page on https://app.example.com calls your API on another domain. The browser first sends a preflight OPTIONS request, and only continues if the response has the right headers. Next.js does not add these headers for you in a route handler. You export an OPTIONS function and add headers yourself:

JavaScript
// app/api/cors/route.tsconst allowed = new Set(['https://app.example.com'])function corsHeaders(origin: string | null) {  const h: Record<string, string> = { Vary: 'Origin' }  if (origin && allowed.has(origin)) {    h['Access-Control-Allow-Origin'] = origin    h['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS'    h['Access-Control-Allow-Headers'] = 'Content-Type, Authorization'  }  return h}export async function OPTIONS(request: Request) {  return new Response(null, { status: 204, headers: corsHeaders(request.headers.get('origin')) })}export async function GET(request: Request) {  return Response.json({ data: 'public data' }, { headers: corsHeaders(request.headers.get('origin')) })}
TEXT
$ curl -i -X OPTIONS localhost:3501/api/cors -H 'Origin: https://app.example.com' -H 'Access-Control-Request-Method: POST'HTTP/1.1 204 No Contentaccess-control-allow-origin: https://app.example.comaccess-control-allow-methods: GET, POST, OPTIONSaccess-control-allow-headers: Content-Type, Authorizationvary: Origin$ curl -i localhost:3501/api/cors -H 'Origin: https://evil.example'HTTP/1.1 200 OKvary: Origin          (no access-control-allow-origin header)

For the allowed origin, the browser gets the permission headers. For another origin, there is no Access-Control-Allow-Origin, so a browser would block the page from reading the answer. Two things to remember. I echo the origin only if it is in an allow-list (never * together with cookies). And the Vary: Origin header tells caches that the answer depends on the origin.

CORS protects the user's browser, not your server. The evil.example request above still got 200 and the data: curl ignores CORS. If the data is private, you must check credentials. CORS only decides which websites' scripts may read the answer.

Security rules for route handlers

The docs start their security section for route handlers with one sentence: Route Handlers are public HTTP endpoints. Any client can call them. From that:

  1. Authenticate and authorize inside the handler (lesson 5). Do not rely on a page being hidden, or on Proxy alone.
  2. Validate every input: path params, query, headers, body. Check content type and size too. I used zod above.
  3. Rate limit. The docs show a handler that calls a checkRateLimit function and returns 429 when the limit is exceeded, and advise that you also turn on your host's rate limiting.
  4. Verify payloads from other services (signature check as above).
  5. Return only what the caller needs. Remove sensitive fields from responses and logs.
  6. Rotate keys and keep secrets in environment variables (lesson 8).

Do not call your own route handlers from Server Components

It is tempting to write a GET /api/posts handler and then fetch('http://localhost:3000/api/posts') from a Server Component. The docs advise against it. For a page prerendered at build time it fails the build, because no server is listening yet. For a page rendered on demand, it adds an extra HTTP round trip between the handler and the render. Put the logic in a plain function (for example getPosts() in lib/), and call that function from both the page and the handler.

When to pick what

You need...Use
Read data to render a pageServer Component (lesson 2)
Change data from your own form or buttonServer Action (lesson 3)
A public URL for a mobile app, webhook, partner or scriptRoute Handler
Standard HTTP features: custom status codes, headers, GET caching, streaming, many parallel requestsRoute Handler
Act on requests before a page or handler runs (redirect, rewrite, header)Proxy or next.config
Live or polled data in the browserA library like SWR or TanStack Query, calling a route handler

Proxy (formerly middleware)

Proxy is code that runs before a request is completed. In Next.js 16 it is a file named proxy.ts in the project root (or in src/, next to app). Older versions called it middleware.ts. The docs say the function is the same, only the name changed. I tried the old name to see what happens:

TEXT
⚠ The "middleware" file convention is deprecated. Please use "proxy" instead.  npx @next/codemod@canary middleware-to-proxy .

The old name still ran, with a warning. Use proxy in new code.

The file exports one function (named proxy, or as the default export) and an optional config. The function receives a NextRequest and can return a response or pass the request on with NextResponse.next(). Here is the one I tested:

JavaScript
// proxy.tsimport { NextResponse } from 'next/server'import type { NextRequest } from 'next/server'export function proxy(request: NextRequest) {  const { pathname } = request.nextUrl  console.log('[proxy]', request.method, pathname)  if (pathname === '/old-blog') {    return NextResponse.redirect(new URL('/blog', request.url), 308)  }  if (pathname === '/pretty') {    return NextResponse.rewrite(new URL('/about', request.url))  }  if (pathname.startsWith('/dashboard') && !request.cookies.has('session')) {    const url = new URL('/login', request.url)    url.searchParams.set('from', pathname)    return NextResponse.redirect(url)  }  // pass a value to the app through a request header, and add a response header  const requestHeaders = new Headers(request.headers)  requestHeaders.set('x-user-hint', request.cookies.get('session') ? 'logged-in' : 'anonymous')  const response = NextResponse.next({ request: { headers: requestHeaders } })  response.headers.set('x-request-id', crypto.randomUUID())  return response}

Results with curl -i against next start:

TEXT
/old-blog                      HTTP/1.1 308 Permanent Redirect   location: /blog/pretty                        HTTP/1.1 200 OK    (page shows "About", URL stays /pretty)/dashboard   (no cookie)       HTTP/1.1 307 Temporary Redirect   location: /login?from=%2Fdashboard/dashboard   (cookie session)  HTTP/1.1 200 OK   x-request-id: a75bf0ca-...   page: "Dashboard (hint: logged-in)"/about                         HTTP/1.1 200 OK   x-request-id: 74157118-...

What each tool did:

  • Redirect sent the browser to a new URL (the URL changes). I chose 308, a permanent redirect. The default is 307.
  • Rewrite answered /pretty with the content of /about, and the address bar still says /pretty. Useful for A/B tests or friendly URLs.
  • Auth redirect: a visit to /dashboard without a session cookie is redirected to /login?from=/dashboard.
  • Headers: NextResponse.next({ request: { headers } }) changes the headers that your app receives (the dashboard page read x-user-hint). response.headers.set(...) changes the headers the client gets (x-request-id). The docs stress the difference: request headers set in Proxy are not exposed to the client.

Note that /about is a static page (○), and it still got a fresh x-request-id on each request. Proxy runs for every request, even for prerendered pages and files. The route table also shows a line ƒ Proxy (Middleware) after build.

The matcher: where does Proxy run?

My first version had no config. Without a matcher, the docs say Proxy runs on every request, including static files and images. I checked the log after asking for a file in public/ and a JavaScript chunk:

TEXT
[proxy] GET /robots.txt[proxy] GET /_next/static/chunks/0cegfsgm6lvdz.js

Both went through my function. If a Proxy redirects anonymous users to /login, it would also redirect their requests for CSS, JS and images, and the login page itself would break. So set a matcher. Based on the docs example, this one excludes API routes, static assets and files with extensions:

JavaScript
export const config = {  matcher: ['/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)'],}

With it in place, /robots.txt and /api/hello no longer logged anything. Matcher values must be constants (they are analysed at build time), and the source patterns start with /, support :param, *, +, ? and regular expressions in parentheses. You can also add has and missing conditions on headers, cookies and query.

Where Proxy sits in the order

The docs list the order in which things run: first headers from next.config, then redirects from next.config, then Proxy, then the beforeFiles rewrites, then files in public/ and routes in app/, then afterFiles rewrites, dynamic routes, and fallback rewrites. I made config entries to see it:

JavaScript
// next.config.tsconst nextConfig: NextConfig = {  async redirects() { return [{ source: '/cfg-old', destination: '/blog', permanent: true }] },  async rewrites() { return [{ source: '/cfg-pretty', destination: '/about' }] },  async headers() { return [{ source: '/:path*', headers: [{ key: 'x-from-config', value: 'yes' }] }] },}
TEXT
/cfg-old     308 Permanent Redirect  location: /blog      (the proxy log has NO line for /cfg-old)/cfg-pretty  200  x-from-config: yes  x-request-id: ...   (proxy log: "[proxy] GET /cfg-pretty")/robots.txt  200  x-from-config: yes                      (no proxy: excluded by the matcher)

The config redirect was answered before Proxy ever ran. The config rewrite happened after Proxy (Proxy saw the original /cfg-pretty). The docs advice follows: for simple, fixed redirects and rewrites, use next.config first. Use Proxy when you need request data (cookies, headers) or logic.

NeedUse
A fixed old URL goes to a new URLredirects in next.config.ts
A fixed pretty URL maps to a routerewrites in next.config.ts
The same header on many routesheaders in next.config.ts
Redirect or rewrite based on a cookie, header, country, A/B bucketProxy
Add a request id, pass a hint to the app, log requestsProxy
Real authorizationNot Proxy: check close to the data (lesson 5)

What Proxy is not for

The docs are clear about limits:

  • Not for slow data fetching. It runs before everything on every matched request, so work done there delays everything. Also, fetch options like cache, next.revalidate and next.tags have no effect inside Proxy.
  • Not a full session or authorization system. It is fine for "optimistic" checks, like redirecting users who have no session cookie, but it should not be the only protection.
  • Not for shared state. Proxy may run separately from your render code (in optimised cases it is deployed to a CDN), so do not rely on shared modules or global variables. Pass information using headers, cookies, rewrites, redirects or the URL.
  • Runtime: Proxy uses the Node.js runtime by default, and the docs say you cannot set a runtime option in this file (it throws).
  • One file per project. Split the logic into modules and import them into proxy.ts.

The Proxy docs add a warning that I think every team should read. Server Actions are not separate routes. They are POST requests to the page where they are used. So a Proxy matcher that excludes a path also skips action calls on that path, and a refactor that moves an action to a different route can quietly remove Proxy coverage. This is one more reason why every action and every handler must check access itself.

The forged header trap

My Proxy sets a request header, x-user-hint, that the app trusts. Is it safe? It depends on whether Proxy always runs. I sent a request with my own x-user-hint: admin header to two places:

TEXT
$ curl -H 'x-user-hint: admin' localhost:3501/whoami      (page covered by Proxy)hint = anonymous$ curl -H 'x-user-hint: admin' localhost:3501/api/me      (API path excluded by the matcher){"theme":null,"userAgent":"curl/8.5.0","auth":"missing","pathname":"/api/me","hint":"admin"}

On the covered page, Proxy overwrote my header (set), so the page saw anonymous. On the excluded route, Proxy never ran, and my forged admin value went straight through to the handler. If that handler had trusted x-user-hint, any caller could have been an admin.

The lessons: (1) never base security on a header that a client can send, unless you are sure Proxy always sets or deletes it for that route; (2) prefer to re-check the session where the data is read; (3) be careful when you change a matcher.

Common mistakes

1. Treating Proxy as your login system. It is an optimistic filter. Check the session again in the page, action or handler. I forged a header past an excluded route.

2. A Proxy with no matcher. It runs on _next/static, images and public/ files. A redirect rule can break your own CSS and JS.

3. Putting slow work in Proxy. A database call there delays every request. Use a cookie check, then do the real work later.

4. Returning 200 for errors. Return the right status: 400 or 422 for bad input, 401 or 403 for access, 404 for missing, 429 for too many requests. Keep one error shape.

5. No validation of the body. await request.json() throws on bad JSON and gives unknown otherwise. Parse it with a schema and return 400/422.

6. Parsing the body before checking a webhook signature. Use the raw text for the HMAC, then parse.

7. Access-Control-Allow-Origin: * with credentials. Use an allow-list and echo the origin, plus Vary: Origin.

8. Calling your own route handler from a Server Component. Share a function instead. The docs say it fails the build for prerendered pages.

9. Expecting GET handlers to be cached (or not). In the default model they are dynamic unless you export dynamic = 'force-static' or revalidate. Read the route table.

10. Still using middleware.ts. It works with a warning. Rename to proxy.ts (the codemod does it).

Try it

Make a small app. Answers follow.

  1. Create app/api/hello/route.ts that returns Response.json({ ok: true }). Run curl -i on it. Then call it with -X POST. What do you get?
  2. Add app/api/users/[id]/route.ts with GET. Use await ctx.params. Return 404 JSON for an unknown id.
  3. Add a POST that validates a JSON body with zod. Test good JSON, wrong fields, and not json. Which status codes would you choose?
  4. Build the app. Look at the route table: are your GET handlers ○ or ƒ? Add export const dynamic = 'force-static' to one and build again.
  5. Make a webhook handler that checks an HMAC signature. Create the signature with openssl dgst -sha256 -hmac secret. Test with a good signature, a bad one, and a changed body.
  6. Add proxy.ts that redirects /old to /new and adds a x-request-id header. Do you see the header on a static page? Add a console.log and request /robots.txt. Is it logged?
  7. Add a matcher that excludes _next/static, api and files with extensions. Check the log again.
  8. Make Proxy set x-user-hint on matched paths, then send your own x-user-hint: admin to a path that is excluded. What does the handler see?

Answers

  1. GET returns 200 with the JSON. POST returns 405 Method Not Allowed, because you only exported GET.
  2. const { id } = await ctx.params. An unknown id returns Response.json({ error: ... }, { status: 404 }).
  3. 201 for a created item, 422 (or 400) for wrong fields, 400 for unparseable JSON.
  4. In the default model, GET handlers are ƒ (dynamic). With force-static the route becomes ○ and its output is frozen at build time.
  5. Good signature: 200. Wrong signature and changed body: 401.
  6. Yes, the static page gets the header on every request, because Proxy runs for all matched requests. Without a matcher, /robots.txt is logged too.
  7. The static file and API paths no longer appear in the log.
  8. The handler sees admin, because Proxy did not run for that path and the client's header passed through.

Interview questions

What is a Route Handler and when do you use one instead of a Server Action? (Must know)

A Route Handler is a route.ts file that exports functions named after HTTP methods and returns a standard Response. I use it when the caller is not my own UI: a mobile app, a webhook, a partner service, a script. I also use it when I need standard HTTP features like a cacheable GET, custom status codes, headers, streaming or parallel reads. For my own forms and buttons that change data I use a Server Action, which gives me progressive enhancement and a one-round-trip refresh.

Which HTTP methods does a route handler support, and what happens with the others? (Must know)

GET, POST, PUT, PATCH, DELETE, HEAD and OPTIONS. If I do not export a method, Next.js returns 405 Method Not Allowed. I tested it with a PATCH on a route that only exported GET and DELETE. HEAD worked automatically, and OPTIONS answered 204 with an Allow header listing the methods.

Are route handlers cached? (Senior signal)

In the default model, GET handlers are not cached. My /api/hello was ƒ in the route table and returned a new value on each request, even though it used no request data. To cache one, I export dynamic = 'force-static' (built once, I saw the same timestamp every time) or revalidate = 3 (stale-while-revalidate). Other methods are never cached. With cacheComponents the docs say handlers follow the page model: they can be prerendered when they use no uncached or runtime data, and they use use cache in a helper function.

How do you read params, query, body, cookies and headers in a route handler? (Must know)

params come from the second argument and are a Promise, so const { id } = await ctx.params. Query: new URL(request.url).searchParams or request.nextUrl.searchParams. Body: await request.json(), request.text() or request.formData(), and I validate the result with a schema. Cookies and headers: await cookies() and await headers() from next/headers, or request.cookies and request.headers. To set a cookie I put it on a NextResponse, with httpOnly and sameSite.

How do you secure a webhook endpoint? (Senior signal)

I verify a signature. The sender computes an HMAC of the raw body with a shared secret and sends it in a header. I read the body with request.text(), compute the HMAC, and compare with timingSafeEqual, then parse JSON. I tested a good signature (200), a wrong one (401), and a body changed by one character with the old signature (401). I also make the handler idempotent because providers retry, and I answer quickly and do slow work after.

What is CORS, and does it protect my API? (Trap)

CORS is a browser rule: a page from one origin can read a response from another origin only if the response has the right Access-Control-Allow-* headers, and for non-simple requests the browser sends an OPTIONS preflight first. In a route handler I export OPTIONS and add the headers myself, with an allow-list and Vary: Origin. It does not protect my API. In my test, curl with a bad Origin still got 200 and the data. Private data needs authentication.

What is Proxy, and how is it different from middleware? (Must know)

Proxy is the file proxy.ts that runs before a request is completed, and it can redirect, rewrite, change headers or respond directly. In Next.js 16 it is the new name for middleware. The functionality is the same. The old middleware.ts still ran in my test but printed a deprecation warning, and there is a codemod to rename it. Proxy uses the Node.js runtime by default.

Redirect or rewrite? And next.config or Proxy? (Must know)

A redirect tells the browser to go to a new URL, so the address bar changes (308 permanent, 307 temporary). A rewrite serves another route's content under the same URL: my /pretty showed the About page and stayed /pretty. For fixed rules I use redirects, rewrites and headers in next.config.ts, which run before Proxy. I use Proxy when the decision depends on request data like cookies, headers or an experiment bucket.

What does the matcher do, and what happens without one? (Senior signal)

It limits the paths Proxy runs on. Without one, Proxy runs on every request, including _next/static, images and files in public/. I saw /robots.txt and a JS chunk in my log. A redirect for anonymous users would then block your own CSS and scripts. I use a negative matcher that excludes api, _next/static, _next/image and files with extensions, and I know that matcher values must be constants.

Can you protect your app with Proxy only? (Trap)

No. The docs call it fine for optimistic checks, like redirecting users with no session cookie, but not a full session or authorization solution. It can also be skipped: an excluded path does not run it, and Server Actions are POST requests to a page route, so a matcher that skips the path also skips Proxy for the action. I showed a forged x-user-hint: admin header reaching a handler on an excluded path. I check the session again close to the data, in the page, action or handler.

How does Proxy pass information to the app? (Senior signal)

Through headers, cookies, rewrites, redirects or the URL, because Proxy may run separately from the render code and should not share globals. For headers I use NextResponse.next({ request: { headers } }) to change what the app receives (not exposed to the client), and response.headers.set() for what the client gets. I always overwrite or delete a header that the app trusts, so a client cannot supply its own value.

Why should a Server Component not call your own route handler with fetch? (Trap)

It adds an extra HTTP round trip to your own server for no reason. For a page prerendered at build time it can fail the build, since no server is running. I move the logic into a shared function in lib/ and call it from both the page and the handler.

Key takeaways

  • A Route Handler is route.ts with functions named GET, POST and so on, taking a Request and returning a Response. A folder cannot have both page and route.
  • params is a Promise. Validate query, body and headers. Use the right status codes (201, 204, 400, 422, 404, 405) and one error shape.
  • In the default model GET handlers are dynamic (ƒ). Use force-static or revalidate to cache. Only GET can be cached.
  • A response can stream. A webhook needs a signature check on the raw body with timingSafeEqual. CORS is a browser rule and does not protect your data.
  • Route handlers are public endpoints: authenticate, authorize, validate, rate limit.
  • Proxy (proxy.ts, formerly middleware) runs before matched requests: redirect, rewrite, change headers. Set a matcher or it runs on static files too.
  • Use next.config for fixed redirects, rewrites and headers. They run before Proxy.
  • Proxy is not authorization, not for slow work, and can be skipped. A client can forge any header Proxy does not overwrite.

What's next

Next: Authentication and Authorization in Next.js. You now have all the doors into your app: pages, actions, route handlers and Proxy. Each one needs a check. You will build sessions with cookies, check access close to the data, use Proxy only for the cheap early check, and protect actions and handlers the same way. Practise now: write a route handler with a validated POST, and a proxy.ts with a matcher, then send a forged header to a path the matcher skips.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series