Guides

Why does NextResponse.redirect send users to localhost behind nginx?

Under next start, a route handler's request.url is built from the address Next itself listens on, not from the Host header your proxy forwards. It does take the scheme from X-Forwarded-Proto, so behind nginx it reads https://localhost:3000/... and any redirect built with new URL(path, request.url) sends the user there. Build absolute URLs from a public origin you configure, send a relative Location header, or read the forwarded host when you trust the proxy that sets it.

What happens

We hit this in our own app: a form handler redirected with NextResponse.redirect(new URL(path, request.url)), and in production the browser landed on https://localhost:3030. nginx was passing the right Host header. We reproduced it in a fresh Next.js 16.4.5 app under next build and next start, with this route:

app/u/route.tstypescript
import { NextRequest, NextResponse } from 'next/server';

export function GET(req: NextRequest) {
  return Response.json({
    url: req.url,
    host: req.headers.get('host'),
    redirectTo: NextResponse.redirect(new URL('/x', req.url)).headers.get('location'),
  });
}
Request headersrequest.urlRedirect Location
Host: example.com, X-Forwarded-Proto: httpshttps://localhost:44582/uhttps://localhost:44582/x
Host: example.comhttp://localhost:44582/uhttp://localhost:44582/x

The scheme follows X-Forwarded-Proto, but the host and port are the server's own, whatever Host header arrives. Local development hides it, because there the browser really is talking to localhost.

Fix 1: build URLs from a public origin you configure

The most reliable fix. Set the site's public address once and use it for every absolute URL: redirects, emails, OAuth callbacks.

lib/origin.tstypescript
export const publicOrigin = () => process.env.PUBLIC_ORIGIN ?? 'http://localhost:3000';

// in a route handler
return NextResponse.redirect(`${publicOrigin()}/dashboard`, 303);

Fix 2: send a relative Location

A redirect doesn't need an absolute URL: browsers resolve a relative Location against the page they asked for. NextResponse.redirect wants an absolute URL, so return a plain Response. In our test this came back as location: /dashboard.

app/api/resolve/route.tstypescript
export function POST() {
  return new Response(null, { status: 303, headers: { Location: '/dashboard' } });
}

Fix 3: read the forwarded host, only behind a proxy you control

The headers are there: in our test, reading them gave https://example.com/x. But anyone can send these headers to a server that is reachable directly, and an attacker who controls the host in your redirects or password-reset links has a phishing link with your name on it. Use this only when the app is reachable solely through a proxy that sets the headers itself.

lib/request-origin.tstypescript
export function requestOrigin(req: Request): string {
  const host = req.headers.get('x-forwarded-host') ?? req.headers.get('host');
  const proto = req.headers.get('x-forwarded-proto') ?? 'http';
  return `${proto}://${host}`;
}

Find the other places it bites

  • Search for new URL(..., request.url) and req.nextUrl in anything that ends up in a Location header, an email or a callback URL.
  • Reading searchParams from request.url is fine: the path and query are right, only the origin is wrong.
  • Test behind the real proxy, or curl the server with a Host header that isn't localhost and check the Location you get back.

Questions

Does this happen in next dev?
It does the same thing, but you won't notice: in development the browser is on localhost, so a localhost redirect works.
Is it the same on Vercel?
We tested next start behind our own nginx, not Vercel. Check your own deployment with a request whose Host is your real domain.

Related