localhost:5173 / proxy-rewrite

Rewriting the path when the Vite proxy forwards a request

rewrite is called with req.url — the full path including the query string — and whatever it returns replaces req.url before the request is forwarded, so it has to come back as a path that still starts with a slash.

error
GET /users 404 (Not Found)

The proxy does not strip the prefix on its own

A key of /api forwards /api/users to the target as /api/users. Nothing is removed. That is the whole reason rewrite exists, and it is the first surprise for anyone coming from a setup where the prefix was virtual.

vite.config.js
proxy: {
  '/api': {
    target: 'http://localhost:8080',
    changeOrigin: true,
    rewrite: (path) => path.replace(/^\/api/, ''),
  },
}

# /api/users?page=2  ->  http://localhost:8080/users?page=2

What rewrite actually gets handed

Not a pathname. Vite calls rewrite with req.url, which carries the query string as well, and assigns the return value straight back to req.url. Anything that treats the argument as a bare path and rebuilds a URL from it drops everything after the question mark — a filter or a page number that silently stops arriving is usually this.

vite source — middlewares/proxy.ts (v8.3.0)
if (opts.rewrite) {
  req.url = opts.rewrite(req.url!)
}

Why the 404 is almost always an empty string

The regex that strips the prefix also strips the whole path when the request is the prefix itself. /api/users becomes /users, but /api becomes "" — not "/". The return value is written to req.url unchanged, so the backend is asked for something that is not a path at all. Add the slash back instead of hoping the request always has a segment behind it.

node — reproduced 15.09.2026
> '/api/users?x=1'.replace(/^\/api/, '')
'/users?x=1'

> '/api'.replace(/^\/api/, '')
''

# safe version: guarantee a leading slash
rewrite: (path) => path.replace(/^\/api/, '') || '/',

A rewrite cannot make a rule match

The match runs first, against the original URL, and only then is rewrite applied. A key without a leading ^ is compared with startsWith; a key that begins with ^ is compiled to a RegExp. So rewriting a path into something the key would have matched changes nothing — the request was already routed, or it never entered the proxy.

vite source — createProxyContextMatcher (v8.3.0)
if (context[0] === '^') {
  const regex = new RegExp(context)
  return (url) => regex.test(url)
}
return (url) => url.startsWith(context)

Non-relative base changes every key

If base is set to something other than "/" or a relative value, the documentation requires each proxy key to carry that base as a prefix. The requests leaving the browser have it, the keys do not, and nothing matches — which reads exactly like a broken rewrite but happens a step earlier.

vite.config.js
export default defineConfig({
  base: '/app/',
  server: {
    proxy: {
      // '/api' would never match — the browser sends /app/api/...
      '/app/api': {
        target: 'http://localhost:8080',
        rewrite: (path) => path.replace(/^\/app\/api/, '') || '/',
      },
    },
  },
})

See the path instead of guessing it

Vite logs every proxied request under the debug namespace vite:proxy, and in the HTTP path that line is printed before rewrite runs — it shows the URL as it arrived. So it tells you that the rule matched, not what the target receives. For the rewritten path, log it on the backend or inside the rewrite function itself.

bash
$ DEBUG=vite:proxy npm run dev

# the URL shown is the incoming one, before rewrite
  vite:proxy /api/users?page=2 -> http://localhost:8080

# no line at all = the rule never matched

When you want one path to skip the proxy

bypass runs before rewrite and decides whether the request is proxied at all. Return a string and Vite sets req.url to it and hands the request back to its own middleware chain — that serves a local file, it does not forward anything. Return false and the response is a 404. Return nothing and the request continues to the proxy as usual.

vite.config.js
'/api': {
  target: 'http://localhost:8080',
  bypass(req) {
    // served by Vite, not forwarded
    if (req.headers.accept?.includes('text/html')) return '/index.html'
    // 404, straight away
    if (req.url === '/api/private') return false
    // undefined -> proxy it
  },
}

# faq

Questions

Does the Vite proxy remove the path prefix automatically?

No. A key of /api forwards /api/users as /api/users. Only a rewrite function changes the path.

Why does my proxy return 404 for /api but work for /api/users?

Because path.replace(/^\/api/, "") returns an empty string for /api itself, and that empty string is assigned to req.url. Append || "/" to the rewrite.

Does rewrite receive the query string?

Yes. It is called with req.url, so the query is part of the argument and part of what you must return — rebuilding only the pathname discards it.

Can I use rewrite to make a request match a proxy rule?

No. The key is compared against the original URL before rewrite runs, with startsWith, or as a RegExp when the key starts with ^.

Does DEBUG=vite:proxy show the rewritten path?

No. In the HTTP path the debug line is printed before rewrite runs, so it shows the incoming URL. It confirms that a rule matched; the rewritten path has to be read on the backend.

# next

Related