How to Fix: Unhandled rejection when using rewrite proxy

7 min read

A rewrite-based proxy can look correct in next.config.js and still crash with an unhandled rejection the moment the upstream request fails. In this issue, the failure happens because the rewritten request is treated like a transparent proxy hop, but the upstream response path is not being safely handled when the target is unavailable or responds in a way the runtime does not recover from cleanly.

Understanding the Root Cause

The issue appears when a Next.js rewrite is used like a backend proxy, for example mapping /proxy-path to another server. A rewrite changes the destination of the request internally, but it does not give you the same level of control as a custom route handler or middleware-based proxy.

When the browser or curl hits the rewritten path, Next forwards the request to the configured destination. If that destination fails, closes the connection, returns malformed data, or is unreachable, the error can bubble up through the underlying fetch/proxy pipeline as a rejected promise. In this case, that rejection is not converted into a clean HTTP response, so development mode shows an unhandled rejection.

Technically, the root problem is this: rewrites are routing rules, not resilient proxy error handlers. They are great for path remapping, but once you depend on them for backend fault handling, timeout handling, header shaping, or graceful fallback behavior, you are outside their ideal use case.

This is why the bug is reproducible with a simple request flow:

  1. The client requests /proxy-path.
  2. Next applies a rewrite to an upstream URL.
  3. The upstream request rejects or fails during forwarding.
  4. The rejection is not safely transformed into a response object.
  5. Node logs an unhandled rejection instead of returning a controlled error payload.

If you need reliable behavior, the fix is to move proxying into code you control, where you can wrap the upstream call in try/catch, validate the response, forward only the headers you want, and return a known fallback response.

Step-by-Step Solution

The safest fix is to replace the rewrite-based proxy with an explicit Route Handler or API endpoint. That gives you control over failures and prevents unhandled promise rejections.

1. Remove the rewrite rule for the proxy path

If your next.config.js contains a rewrite similar to this, remove it for the failing endpoint:

module.exports = {
  async rewrites() {
    return [
      {
        source: '/proxy-path',
        destination: 'http://localhost:4000/proxy-path',
      },
    ]
  },
}

This rewrite is the source of the fragile behavior. Keep rewrites only for simple path remapping where you do not need error handling.

2. Create a route handler that performs the upstream fetch safely

If you are using the App Router, create app/proxy-path/route.ts:

import { NextRequest, NextResponse } from 'next/server'

export async function GET(req: NextRequest) {
  try {
    const upstream = await fetch('http://localhost:4000/proxy-path', {
      method: 'GET',
      headers: {
        accept: req.headers.get('accept') || '*/*',
      },
      cache: 'no-store',
    })

    const body = await upstream.text()

    return new NextResponse(body, {
      status: upstream.status,
      headers: {
        'content-type': upstream.headers.get('content-type') || 'text/plain; charset=utf-8',
      },
    })
  } catch (error) {
    console.error('Proxy request failed:', error)

    return NextResponse.json(
      {
        error: 'Upstream proxy request failed',
      },
      {
        status: 502,
      }
    )
  }
}

This solves the problem because the upstream failure is now wrapped in a try/catch, and any rejected promise becomes a controlled 502 Bad Gateway response instead of an unhandled rejection.

3. If you use the Pages Router, create an API route instead

For older projects, create pages/api/proxy-path.ts:

import type { NextApiRequest, NextApiResponse } from 'next'

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  try {
    const upstream = await fetch('http://localhost:4000/proxy-path')
    const body = await upstream.text()

    res.status(upstream.status)
    res.setHeader(
      'content-type',
      upstream.headers.get('content-type') || 'text/plain; charset=utf-8'
    )
    res.send(body)
  } catch (error) {
    console.error('Proxy request failed:', error)
    res.status(502).json({ error: 'Upstream proxy request failed' })
  }
}

Then call /api/proxy-path instead of relying on a rewrite.

4. Forward query parameters if needed

If the original request includes a query string, preserve it explicitly:

import { NextRequest, NextResponse } from 'next/server'

export async function GET(req: NextRequest) {
  try {
    const url = new URL(req.url)
    const upstreamUrl = new URL('http://localhost:4000/proxy-path')
    upstreamUrl.search = url.search

    const upstream = await fetch(upstreamUrl.toString(), {
      cache: 'no-store',
    })

    const body = await upstream.text()

    return new NextResponse(body, {
      status: upstream.status,
      headers: {
        'content-type': upstream.headers.get('content-type') || 'text/plain; charset=utf-8',
      },
    })
  } catch (error) {
    console.error(error)
    return NextResponse.json({ error: 'Proxy failed' }, { status: 502 })
  }
}

This avoids subtle bugs where the original request and upstream request stop matching.

5. Forward method and body for non-GET requests

If the proxy endpoint must support POST, PUT, or PATCH, forward the request body too:

import { NextRequest, NextResponse } from 'next/server'

export async function POST(req: NextRequest) {
  try {
    const body = await req.text()

    const upstream = await fetch('http://localhost:4000/proxy-path', {
      method: 'POST',
      headers: {
        'content-type': req.headers.get('content-type') || 'application/json',
      },
      body,
    })

    const responseBody = await upstream.text()

    return new NextResponse(responseBody, {
      status: upstream.status,
      headers: {
        'content-type': upstream.headers.get('content-type') || 'text/plain; charset=utf-8',
      },
    })
  } catch (error) {
    console.error('POST proxy failed:', error)
    return NextResponse.json({ error: 'Proxy failed' }, { status: 502 })
  }
}

6. Verify the fix locally

Run the app and hit the endpoint again:

pnpm install
pnpm run dev
curl -i http://localhost:3000/proxy-path

Expected behavior after the fix:

  • If the upstream server is healthy, you get its response.
  • If the upstream server is down or invalid, you get a controlled 502 response.
  • You no longer see an unhandled rejection crashing the request flow.

7. Optional hardening for production

Add a timeout using AbortController so hanging upstream requests do not tie up server resources:

import { NextResponse } from 'next/server'

export async function GET() {
  const controller = new AbortController()
  const timeout = setTimeout(() => controller.abort(), 5000)

  try {
    const upstream = await fetch('http://localhost:4000/proxy-path', {
      signal: controller.signal,
    })

    const body = await upstream.text()
    return new NextResponse(body, { status: upstream.status })
  } catch (error) {
    return NextResponse.json({ error: 'Upstream timeout or failure' }, { status: 502 })
  } finally {
    clearTimeout(timeout)
  }
}

This is much safer than assuming a rewrite can absorb all network failures.

Common Edge Cases

1. The upstream target is not reachable from the Next.js runtime
Localhost assumptions often break in containers, CI, and serverless deployments. A URL that works on your laptop may fail in production because the target host is not resolvable from the runtime environment.

2. Response headers are copied blindly
Some headers should not be forwarded as-is. Be careful with content-length, transfer-encoding, connection, and some CORS headers. Prefer selectively forwarding safe headers.

3. Non-GET requests lose the original body
If you replace a rewrite with a route handler but only implement GET, your proxy will appear fixed while POST and PUT still fail. Add handlers for every method you use.

4. Streaming responses behave differently
If the upstream endpoint streams data, reading with await upstream.text() buffers the entire response. That may be fine for JSON, but not for large or streamed payloads. In those cases, return the ReadableStream directly.

5. Redirects from the upstream service
The upstream may return 301, 302, or 307. Decide whether to preserve those responses or follow them internally with fetch options.

6. Development and production can differ
Some proxy behavior is easier to reproduce in development because of different server layers, logging behavior, and stack traces. Always validate the fix in both environments.

7. Middleware is not a full proxy replacement
Using middleware for complex body forwarding or custom proxy logic is usually the wrong layer. A route handler or API route is the better place for resilient request/response control.

FAQ

Why does a rewrite fail differently from an API route?

A rewrite only remaps the destination path inside Next.js routing. An API route or Route Handler gives you direct control over error handling, response shaping, timeouts, and header forwarding. That is why the latter can return a clean 502 instead of throwing an unhandled rejection.

Can I keep using rewrites if the upstream is stable?

Yes, but only when you are using rewrites for straightforward routing and you do not need robust proxy semantics. If the endpoint is critical or exposed to network instability, a code-based proxy is much safer.

Is this a Next.js bug or an application design problem?

It is primarily a mismatch between what rewrites are designed for and what a production-grade proxy layer needs. The framework can certainly improve how these failures surface, but the practical fix is to stop relying on rewrites for failure-sensitive proxying.

For reference, use the issue reproduction repository linked in the GitHub report: view reproduction project.

Leave a Reply

Your email address will not be published. Required fields are marked *