localhost:5173 / proxy-debug
When the Vite proxy does not forward the request
A proxy that appears to do nothing has either never seen the request — because the browser addressed the backend directly, or because a middleware ahead of it answered first — or it saw it and no key matched, and DEBUG=vite:proxy tells the two apart in one line.
GET http://localhost:5173/api/users 404 (Not Found) # response body is index.html, not JSON
Start with the one line that decides everything
Vite logs every request a proxy rule takes, under the debug namespace vite:proxy. One line per proxied request means the rule matched and the request left for the target. No line at all means no key matched, and nothing you change inside that entry — target, rewrite, changeOrigin — can matter yet. Half the time spent on a broken proxy is spent on the wrong half of this split.
$ DEBUG=vite:proxy npm run dev # matched — the request was forwarded vite:proxy /api/users -> http://localhost:8080 # nothing at all here = no rule matched
The URL in that line is the one that arrived
In the HTTP path of Vite 8.3.0 the debug call runs before rewrite, so what you see is the request as the browser sent it, not the path the backend receives. The upgrade path for WebSockets does it the other way round and logs the rewritten URL. If you are reading the line to check a rewrite, you are reading the wrong end of it — the rewritten path is what your backend logs.
debug?.(`${req.url} -> ${opts.target || opts.forward}`)
if (opts.rewrite) {
req.url = opts.rewrite(req.url!)
}The request never reached 5173
The proxy is a middleware on the dev server. It only ever sees requests the browser sends to localhost:5173. A fetch written against the backend host goes straight there, and no configuration on the dev server is involved at all — the failure that follows is a CORS error or a refused connection, never a proxy problem. The request has to be relative for the proxy to have a say.
# the dev server never sees this fetch('http://localhost:8080/api/users') # this is what the proxy can act on fetch('/api/users')
Why the answer is an HTML document
A path that matches no proxy key falls through to the rest of the dev server, and for a single-page app that ends at the HTML fallback. So the response is 200 with index.html, or a 404, and the parse error in the console is JSON.parse choking on a "<". That symptom is not a broken target — it is a key that did not match.
SyntaxError: Unexpected token '<', "<!doctype "... is not valid JSON # check what the path really was $ curl -i http://localhost:5173/api/users | head -n 1 HTTP/1.1 200 OK
How a key is compared
A key without a leading ^ is compared with startsWith against req.url. Two consequences: the key needs its leading slash, because req.url always has one and "api" can never be a prefix of "/api/users"; and the comparison includes the query string, which only matters for a RegExp key that anchors the end. With a non-relative base, the browser sends the base in front of every path while the key does not have it — the same mismatch, one step earlier.
if (context[0] === '^') {
const regex = new RegExp(context)
return (url) => regex.test(url)
}
return (url) => url.startsWith(context)
# never matches: req.url is '/api/users'
proxy: { 'api': 'http://localhost:8080' }The first matching rule wins, and it is the one you wrote first
The middleware walks the rules in the order the keys were declared and returns on the first match. A broad key placed above a specific one takes every request the specific one was meant to handle, and the specific entry is never reached — no warning, and the debug line names the target of the wrong rule. Order the object from most specific to least.
proxy: {
// matches /api/auth/login first — the entry below is dead
'/api': 'http://localhost:8080',
'/api/auth': 'http://localhost:9000',
}
# specific first
proxy: {
'/api/auth': 'http://localhost:9000',
'/api': 'http://localhost:8080',
}Three things run before the proxy does
In the dev server middleware stack, CORS handling and the allowed-hosts check are installed ahead of the proxy, and so is any middleware a plugin adds directly inside configureServer. A plugin that answers the path first means the proxy is never called. Returning a function from configureServer instead of registering inline defers that middleware until after the internal ones, which puts the proxy back in front of it.
configureServer(server) {
// runs BEFORE the proxy
server.middlewares.use(meins)
// runs after the internal middlewares
return () => {
server.middlewares.use(meins)
}
}502 means the rule worked
When a matched request cannot reach the target, the proxy error handler logs the URL in red and answers 502 with a plain-text body. That is the good failure: routing is correct and the backend is down, bound to a different interface, or listening on another port. Compare it with the silent HTML document above — those two numbers point at opposite halves of the setup.
http proxy error: /api/users Error: connect ECONNREFUSED 127.0.0.1:8080 # the browser gets this HTTP/1.1 502 Bad Gateway Content-Type: text/plain
A rule that was never registered
Entries are set up once at server start, and a falsy value is skipped without a message. A key whose value comes from an environment variable that is not set disappears, and the config still looks right in the editor. The string shorthand has a second surprise: it does not just set the target, it also turns on changeOrigin, which the object form leaves off unless you write it.
let opts = options[context]
if (!opts) {
return
}
if (typeof opts === 'string') {
opts = { target: opts, changeOrigin: true }
}
# gone without a word when API_URL is unset
proxy: { '/api': process.env.API_URL }# faq
Questions
How do I tell whether a Vite proxy rule matched?
Start the dev server with DEBUG=vite:proxy. Every proxied request prints one line. No line means no key matched the path.
Why does my proxy return index.html instead of JSON?
Because no proxy key matched, so the request fell through to the dev server and ended at the SPA fallback document.
Why is my proxy ignored when I call the backend URL directly?
The proxy only sees requests sent to the dev server. An absolute URL to the backend bypasses localhost:5173 entirely, so server.proxy is never involved.
Do Vite proxy rules need a leading slash?
Yes, unless the key is a RegExp starting with ^. A plain key is compared with startsWith against req.url, which always begins with a slash.
Which proxy rule wins when two keys match?
The one declared first. The middleware returns on the first match and never looks at the remaining entries.
What does a 502 from the Vite dev server mean?
The request matched a proxy rule but the target did not answer. Vite logs "http proxy error" with the URL and responds 502 in plain text.
# next
Related
# sources
- Vite — server.proxy
- Vite source — proxy middleware (v8.3.0)
- Vite source — middleware order in server/index.ts (v8.3.0)
- Vite — configureServer hook
Checked against the Vite documentation on 2026-09-17.