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:
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 headers | request.url | Redirect Location |
|---|---|---|
| Host: example.com, X-Forwarded-Proto: https | https://localhost:44582/u | https://localhost:44582/x |
| Host: example.com | http://localhost:44582/u | http://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.
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.
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.
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.