Measured against Next.js 16.1.7 on 2026-09-08, under next dev (Turbopack) and next build && next start.
The Next.js docs show a route handler returning Response.json(...).1 They do not show what happens when it throws, when it returns the wrong thing, or when a stream fails halfway through. There is no error-handling section for route handlers, and the one try/catch in the whole page is an unexplained example under "Webhooks."1
So we measured it. Thirteen cases went into route handlers on Next.js 16.1 - throws, wrong return types, redirect(), notFound(), a stream that fails partway - and for each one we recorded the exact HTTP status, the response headers, and the response body that a client received, once under next dev and once against a production build.
An uncaught throw is a 500 with an empty body and no Content-Type. Throwing a Response with a status on it does not set that status. Returning a plain object is a 500. A stream that throws after its first chunk is already a 200 and stays one. Every case returned byte-for-byte the same thing in development and in production.
The setup
One route, app/api/boom/route.ts, with a ?mode= switch selecting the failure. One app/api/stream/route.ts that enqueues a chunk, waits, then either finishes or calls controller.error(). Both marked dynamic = 'force-dynamic' so nothing is cached. Requests made with curl -D - so the status line and headers are captured verbatim. The production numbers come from next build followed by next start, not from a dev server.
The results
| the handler does | status | body | Content-Type |
|---|---|---|---|
NextResponse.json({ ok: true }) | 200 | {"ok":true} | application/json |
NextResponse.json({ error }, { status: 500 }) | 500 | {"error":"..."} | application/json |
throw new Error("...") | 500 | empty | none |
throw "a bare string" | 500 | empty | none |
await Promise.reject(new Error()) | 500 | empty | none |
throw new Response("x", { status: 418 }) | 500 | empty | none |
throw NextResponse.json(body, { status: 503 }) | 500 | empty | none |
return { hello: "world" } | 500 | empty | none |
return undefined | 500 | empty | none |
redirect("/somewhere") | 307 | empty | none (Location set) |
notFound() | 404 | empty | none |
| stream two chunks, close cleanly | 200 | both chunks | text/plain |
stream one chunk, then controller.error() | 200, then socket closed | first chunk only, truncated | text/plain |
Every row was identical under next dev and under next build && next start.
An uncaught throw is opaque, and it is opaque in development too
Rows three, four and five are the same outcome from three different mistakes: a thrown Error, a thrown string, and a rejected promise you awaited. The client gets:
HTTP/1.1 500 Internal Server Error
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
Date: ...
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
No Content-Type. Zero bytes of body. No error id, no digest header, nothing a caller could branch on beyond the number 500 - which by definition tells them only that "the server encountered an unexpected condition."2
This is also what you get in development, which surprises people who expect next dev to be chattier. It does not put the stack, the message, or an HTML error page into the route handler's response. The only place the error surfaces is the server's own stdout:
- A thrown
Errorlogs⨯ Error: <message>, and in dev it adds the source location and a code frame (at GET (app/api/boom/route.ts:18:13)). In the production build the same line readsat ignore-listed frames- no app frames. - A thrown string logs
⨯ <the string>with no stack, because a string has none. - A rejected promise you forgot to await becomes an
unhandledRejectionand can take the process down, rather than failing the one request.
The dev-versus-prod difference is entirely in that log. The HTTP response is the same empty 500 in both.
Throwing a Response does not set the status
Rows six and seven catch people out. It is reasonable to expect this to work:
throw new Response("I am a teapot", { status: 418 });tsIt does not. Next.js 16.1 does not unwrap a thrown Response or NextResponse. It treats it as an unexpected value that came out of a throw, logs the whole object -
⨯ Response {
status: 418,
statusText: '',
headers: Headers { 'content-type': 'text/plain', 'x-thrown': 'response' },
...
}
- and answers the request with the same bare 500 as any other throw. The 418, the headers, the body you attached to it: all discarded. If you want a status code, you have to return a Response that carries it. Throwing is only ever a 500.
The two exceptions are redirect() and notFound() from next/navigation, and they are exceptions for a specific reason. Both work by throwing - redirect() throws NEXT_REDIRECT, notFound() throws NEXT_HTTP_ERROR_FALLBACK;404.34 Next.js recognises those two sentinel errors by their marker and acts on them before the request reaches the generic 500 path. In a route handler, redirect() produced a 307 with the Location header set, and notFound() produced a bare 404.34 Both bodies were empty.
That last point matters if you were counting on not-found.tsx: in a route handler there is no UI to render, so notFound() is just a 404 status and nothing else.
Returning the wrong type is also a 500
Rows eight and nine: returning a plain object, or returning undefined, from a handler.
export async function GET() {
return { hello: "world" }; // not a Response
}tsNext.js does not try to coerce this into a JSON response. It raises its own error -
⨯ Error: No response is returned from route handler '.../route.ts'.
Ensure you return a `Response` or a `NextResponse` in all branches of your handler.
- and the client gets the empty 500. The message names the file, which helps. But next build compiled this handler without complaint: TypeScript's type for a route handler is loose enough that returning the wrong thing is only caught when the request runs. That message says "all branches" for a reason. A switch with a fall-through, an if with no else, an early return you forgot to make a Response - each is a latent 500.
A stream that fails mid-flight is a truncated 200
The streaming case has no clean answer. The handler returns a Response wrapping a ReadableStream, enqueues chunk-1 sent, waits 50ms, and then calls controller.error(new Error(...)).
By the time the error happens, the response is already on the wire:
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
x-stream: yes
chunk-1 sent
curl: (18) transfer closed with outstanding read data remaining
The status line went out as 200 with the first chunk. It cannot be revised to a 500 afterwards - the header block is sent once, at the top. So controller.error() does the only thing left available: it moves the stream into an errored state and closes the connection, after the chunks already enqueued have been read.5 The client is left holding a partial 200 body and a transport error, and has to notice on its own that the payload is incomplete. Next.js logs ⨯ Error: failed to pipe response with the underlying error as [cause], but the caller never sees it.
Next's own docs acknowledge this shape for pages: once a route has started streaming, "the status can't change once streaming has started," which is why a notFound() inside a Suspense boundary becomes a soft 404.4 The same constraint applies to a raw ReadableStream in a route handler. If a consumer must be able to tell a truncated response from a complete one, the stream needs its own end-of-data marker in the body; the HTTP status will not carry that signal.
There is no error boundary for a route handler
error.tsx and not-found.tsx are part of the page tree. They do nothing for a route handler in the same segment. We put both files next to app/api/boom/route.ts and rebuilt:
Route (app)
┌ ○ /
├ ○ /_not-found
├ ƒ /api/boom
└ ƒ /api/stream
next build did not register either file. sync-throw was still an empty 500; notFound() was still an empty 404. A route handler's only error handling is the code inside the function. If you want anything other than the empty 500 - a shape your client can parse, a request id, a status that reflects the failure - you write it yourself, on every path that can fail.
What this site does instead
This blog runs four route handlers in production: /api/subscribe, /api/newsletter/confirm, /api/newsletter/unsubscribe, and /feed.xml. None of them can reach the framework's empty 500, because every one of them wraps its real work in try/catch (or hands it to a helper that does) and returns an explicit NextResponse on every branch, success and failure alike.
/api/subscribe is the clearest example. It answers with a deliberate ladder of status codes, none of which is ever a thrown error:
400for a body that is not JSON, or an email that does not parse.502when the email provider accepts the call but reports a send failure.503when our own configuration is missing - our bug, flagged as "try later," not the caller's to debug.500, with a JSON body and afterconsole.error, only from the outermostcatch, as the floor rather than the default.
/api/newsletter/confirm never returns JSON at all - it is reached from an email link, so every outcome, including the error case, is a 303 redirect to a page a human can read. The one-click unsubscribe POST returns { ok: false } with a 500 status by choice, so a mail provider retrying on a 5xx sees a real body rather than an empty one.
The measurement above is why the handlers are written that way. The framework's default for an unhandled failure is a 500 with nothing in it, so every path that can fail returns its own Response instead.
What to standardise on
From the results, the rules that fall out:
- Wrap the body of every handler in
try/catch. Thecatchreturns aResponse- a small JSON shape with a stableerrorstring and, ideally, a request id you also logged. This is the only way the client gets aContent-Typeand a parseable body on failure. return, neverthrow, to set a status.throwis a 500, always. A401,409,422or503has to be a returnedResponse.- Call
redirect()andnotFound()outside thetryblock. They work by throwing sentinels, so atry/catcharound them swallows the redirect or the 404. Next documents this explicitly; useunstable_rethrowif you must catch near the call.34 - Make every branch return a
Response. The "No response is returned" error is a 500 at request time, not a compile error. A linter rule on handler return types buys back some of what the type system does not check. - Give a stream its own completion marker. A mid-stream failure is an un-signalable truncated
200. If truncation matters, the body has to say "this is the end," because the status code will not. - Do not rely on
next devto show you the error. It logs to the server console and returns the same empty 500 as production. Your local client sees exactly what a user would.
Where this is weak
- One Next.js version. 16.1.7, App Router, Turbopack. The behaviour of a thrown value is an implementation detail Next has changed before -
GETcaching flipped from static to dynamic in 15,paramsbecame a promise in the same release - and it can change again. - Node runtime only. These handlers ran on the default Node runtime under
next start. The Edge runtime, and adapters like OpenNext on Cloudflare Workers, sit a translation layer below this and may render a thrown error differently. We measured the layer most people deploy, not every layer. curl, not a browser or a fetch client. The status, headers and body are transport-level facts and do not depend on the client, but afetch()caller's behaviour on the truncated stream - whether the promise resolves or rejects - was not part of this and is worth its own test.- No middleware, no
proxy. Errors thrown inproxy.ts(formerlymiddleware.ts) are a separate path with its own defaults and were out of scope here. - The "unhandled rejection takes the process down" claim is environment-sensitive. It reproduced under
next starton Node; a process manager or a serverless runtime that recycles the worker per request will mask it.
FAQ
What HTTP status does a Next.js route handler return when it throws an error?
500, with an empty body and no Content-Type header, on Next.js 16.1. This is true whether you throw an Error, throw a string, or await a rejected promise, and it is the same under next dev and in a production build. The error is written only to the server console; nothing about it reaches the client.
Does next dev show the error message or stack in the route handler response?
No. Unlike a page, a route handler in development returns the same empty 500 as production. The stack and code frame go to the server's stdout only. There is no HTML error overlay for a route handler and no error detail in the response body in any mode.
Can I throw a Response or NextResponse to set the status code in a route handler?
No. Next.js 16.1 does not unwrap a thrown Response; it logs the object and returns a plain 500, discarding your status, headers and body. To send a specific status you must return the Response. The only throw-based helpers Next acts on are redirect() and notFound(), which throw recognised sentinel errors.34
Why does my route handler return 500 when I return a plain object?
A route handler must return a Response or NextResponse. Returning a plain object, or undefined, or falling off the end of a branch, makes Next raise Error: No response is returned from route handler and send an empty 500. It does not coerce the object into JSON. This is a runtime failure, not a build error - next build will compile the handler anyway.
Does error.tsx catch errors from a route handler?
No. error.tsx and not-found.tsx are part of the page tree. next build does not even associate them with a route.ts in the same folder. A route handler's only error handling is a try/catch inside the function.
What happens if a streamed response throws after it has started sending?
The status line has already gone out as 200, so it cannot become a 500. Calling controller.error() closes the connection, leaving the client with a partial 200 body and a transport-level error. Next logs failed to pipe response. If the client needs to distinguish a truncated response from a complete one, the stream must include its own end-of-data marker.
What status code does redirect() use in a route handler?
307 (Temporary Redirect) by default, which preserves the request method; permanentRedirect() uses 308.3 redirect() works by throwing NEXT_REDIRECT, so call it outside any try/catch or the redirect is swallowed.3
Disclosure
The site whose route handlers are described in the second half of this article is nowaterprogramming.com, which is ours. The code inspection and every measurement here are our own primary work; nothing published on the site is used as a source.
Sources
Checked 2026-09-08.
Sources
-
Next.js docs: route.js file convention - that route handlers are built on the Web
RequestandResponseAPIs and return aResponse; thatGET,POST,PUT,PATCH,DELETE,HEADandOPTIONSare supported; and that the only error-handling example shown is atry/catchreturningnew Response(..., { status: 400 })under "Webhooks," with no documented behaviour for a thrown error or a missing return. -
MDN: HTTP 500 Internal Server Error - that
500is a generic "the server encountered an unexpected condition" response with no further indication of what went wrong, which is why a client cannot branch on it beyond the number. -
Next.js docs: redirect - that
redirect()can be used in Route Handlers, throws aNEXT_REDIRECTerror, must be called outside atry/catchblock, serves a307by default to preserve the request method, and thatpermanentRedirect()serves a308. -
Next.js docs: notFound - that
notFound()throws aNEXT_HTTP_ERROR_FALLBACK;404error and terminates the segment, that atry/catcharound it suppresses it, that it "serves a404to the caller" when used in a Route Handler, and that once a response has started streaming "the status can't change." -
MDN: ReadableStreamDefaultController.error() - that
error()causes future interactions with the stream to fail and puts the stream into an errored state, after any already-enqueued chunks have been read.