NoWaterProgramming

What a Next.js Route Handler Does When It Throws: 13 Cases, Measured in Dev and Production

We seeded thirteen failure modes into Next.js 16.1 route handlers and recorded the status, headers and body the client got back, under next dev and under a production build. An uncaught throw is an empty 500 with no Content-Type. Throwing a Response does not set its status. A stream that fails after its first byte is a truncated 200. Dev and production returned the same thing every time.

15 min read
Share:

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 doesstatusbodyContent-Type
NextResponse.json({ ok: true })200{"ok":true}application/json
NextResponse.json({ error }, { status: 500 })500{"error":"..."}application/json
throw new Error("...")500emptynone
throw "a bare string"500emptynone
await Promise.reject(new Error())500emptynone
throw new Response("x", { status: 418 })500emptynone
throw NextResponse.json(body, { status: 503 })500emptynone
return { hello: "world" }500emptynone
return undefined500emptynone
redirect("/somewhere")307emptynone (Location set)
notFound()404emptynone
stream two chunks, close cleanly200both chunkstext/plain
stream one chunk, then controller.error()200, then socket closedfirst chunk only, truncatedtext/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 Error logs ⨯ 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 reads at 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 unhandledRejection and 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 });
ts

It 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
}
ts

Next.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:

  • 400 for a body that is not JSON, or an email that does not parse.
  • 502 when the email provider accepts the call but reports a send failure.
  • 503 when our own configuration is missing - our bug, flagged as "try later," not the caller's to debug.
  • 500, with a JSON body and after console.error, only from the outermost catch, 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:

  1. Wrap the body of every handler in try/catch. The catch returns a Response - a small JSON shape with a stable error string and, ideally, a request id you also logged. This is the only way the client gets a Content-Type and a parseable body on failure.
  2. return, never throw, to set a status. throw is a 500, always. A 401, 409, 422 or 503 has to be a returned Response.
  3. Call redirect() and notFound() outside the try block. They work by throwing sentinels, so a try/catch around them swallows the redirect or the 404. Next documents this explicitly; use unstable_rethrow if you must catch near the call.34
  4. 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.
  5. 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.
  6. Do not rely on next dev to 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 - GET caching flipped from static to dynamic in 15, params became 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 a fetch() 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 in proxy.ts (formerly middleware.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 start on 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

  1. Next.js docs: route.js file convention - that route handlers are built on the Web Request and Response APIs and return a Response; that GET, POST, PUT, PATCH, DELETE, HEAD and OPTIONS are supported; and that the only error-handling example shown is a try/catch returning new Response(..., { status: 400 }) under "Webhooks," with no documented behaviour for a thrown error or a missing return.

  2. MDN: HTTP 500 Internal Server Error - that 500 is 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.

  3. Next.js docs: redirect - that redirect() can be used in Route Handlers, throws a NEXT_REDIRECT error, must be called outside a try/catch block, serves a 307 by default to preserve the request method, and that permanentRedirect() serves a 308.

  4. Next.js docs: notFound - that notFound() throws a NEXT_HTTP_ERROR_FALLBACK;404 error and terminates the segment, that a try/catch around it suppresses it, that it "serves a 404 to the caller" when used in a Route Handler, and that once a response has started streaming "the status can't change."

  5. 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.

Related Posts

18 min read
We put ten defects into real route handlers and ran four testing methods at them: a direct call, next-test-api-route-handler, next start, and the Cloudflare Workers build we deploy. Two methods pass a handler that is broken, and one fails a handler that is fine.
Next.jstesting
By NoWaterProgramming Team
17 min read
How route handlers actually behave in Next.js 16: GET is no longer cached by default, params are async, and most of what you need is a server action instead. With a real deployment that is not on Vercel.
By NoWaterProgramming Team
13 min read
The App Router explained through a site that actually runs on it: server components, async request APIs, generateStaticParams, and the three things that break when you deploy somewhere other than Vercel.
By NoWaterProgramming Team