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.
On this page
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.tsfile in theappfolder that exports functions named after HTTP methods (GET,POST, ...). - HTTP method (verb) says what you want to do:
GETread,POSTcreate,PUT/PATCHchange,DELETEremove. - 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/NextResponseare Next.js extensions of the standardRequestandResponse. They add helpers likerequest.nextUrl,request.cookiesandNextResponse.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
OPTIONSrequest 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.matchersetting 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:
// 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:
$ 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:
$ 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, OPTIONSHEAD 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:
// 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 } })}$ 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:
// 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):
$ 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:
// 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}$ 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=laxThe 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:
├ ƒ /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:
// app/api/static-time/route.tsexport const dynamic = 'force-static'export async function GET() { return Response.json({ at: new Date().toISOString() }) }// 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:
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:
// 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:
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.870The 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.
// 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:
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:
// 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')) })}$ 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:
- Authenticate and authorize inside the handler (lesson 5). Do not rely on a page being hidden, or on Proxy alone.
- Validate every input: path params, query, headers, body. Check content type and size too. I used zod above.
- Rate limit. The docs show a handler that calls a
checkRateLimitfunction and returns429when the limit is exceeded, and advise that you also turn on your host's rate limiting. - Verify payloads from other services (signature check as above).
- Return only what the caller needs. Remove sensitive fields from responses and logs.
- 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 page | Server Component (lesson 2) |
| Change data from your own form or button | Server Action (lesson 3) |
| A public URL for a mobile app, webhook, partner or script | Route Handler |
Standard HTTP features: custom status codes, headers, GET caching, streaming, many parallel requests | Route Handler |
| Act on requests before a page or handler runs (redirect, rewrite, header) | Proxy or next.config |
| Live or polled data in the browser | A 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:
⚠ 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:
// 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:
/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 is307. - Rewrite answered
/prettywith the content of/about, and the address bar still says/pretty. Useful for A/B tests or friendly URLs. - Auth redirect: a visit to
/dashboardwithout asessioncookie is redirected to/login?from=/dashboard. - Headers:
NextResponse.next({ request: { headers } })changes the headers that your app receives (the dashboard page readx-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:
[proxy] GET /robots.txt[proxy] GET /_next/static/chunks/0cegfsgm6lvdz.jsBoth 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:
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:
// 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' }] }] },}/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.
| Need | Use |
|---|---|
| A fixed old URL goes to a new URL | redirects in next.config.ts |
| A fixed pretty URL maps to a route | rewrites in next.config.ts |
| The same header on many routes | headers in next.config.ts |
| Redirect or rewrite based on a cookie, header, country, A/B bucket | Proxy |
| Add a request id, pass a hint to the app, log requests | Proxy |
| Real authorization | Not 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,
fetchoptions likecache,next.revalidateandnext.tagshave 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
runtimeoption 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:
$ 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.
- Create
app/api/hello/route.tsthat returnsResponse.json({ ok: true }). Runcurl -ion it. Then call it with-X POST. What do you get? - Add
app/api/users/[id]/route.tswithGET. Useawait ctx.params. Return 404 JSON for an unknown id. - Add a
POSTthat validates a JSON body with zod. Test good JSON, wrong fields, andnot json. Which status codes would you choose? - Build the app. Look at the route table: are your
GEThandlers○orƒ? Addexport const dynamic = 'force-static'to one and build again. - 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. - Add
proxy.tsthat redirects/oldto/newand adds ax-request-idheader. Do you see the header on a static page? Add aconsole.logand request/robots.txt. Is it logged? - Add a
matcherthat excludes_next/static,apiand files with extensions. Check the log again. - Make Proxy set
x-user-hinton matched paths, then send your ownx-user-hint: adminto a path that is excluded. What does the handler see?
Answers
GETreturns200with the JSON.POSTreturns405 Method Not Allowed, because you only exportedGET.const { id } = await ctx.params. An unknown id returnsResponse.json({ error: ... }, { status: 404 }).201for a created item,422(or400) for wrong fields,400for unparseable JSON.- In the default model,
GEThandlers areƒ(dynamic). Withforce-staticthe route becomes○and its output is frozen at build time. - Good signature:
200. Wrong signature and changed body:401. - Yes, the static page gets the header on every request, because Proxy runs for all matched requests. Without a matcher,
/robots.txtis logged too. - The static file and API paths no longer appear in the log.
- 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.tswith functions namedGET,POSTand so on, taking aRequestand returning aResponse. A folder cannot have bothpageandroute. paramsis 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
GEThandlers are dynamic (ƒ). Useforce-staticorrevalidateto cache. OnlyGETcan 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.configfor 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
- What Makes a Good API: Resources, URLs and the Contract · API Design
- HTTP Methods, Status Codes and Idempotency in Practice · API Design
- Request and Response Design: JSON Shapes, Errors and Validation · API Design
Part of the study guide
Related series