Vite Proxy Works in Dev but 404 in Production? Add Nginx
A Vite project works perfectly in local development. After npm run build and deploying to the server, pages load fine, but every /api request returns 404 β or something sneakier: the response is index.html, and the frontend crashes parsing HTML as JSON.
Encountered this while building an AI Agent SaaS platform for a client with a separated frontend/backend deployment β recording the root cause and the fix.
TL;DRβ
server.proxy in vite.config.ts belongs to the dev server only. npm run build produces plain static files with no proxy layer at all; in production, Nginx must take over /api:
location /api {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
Placed outside the reach of the SPA fallback (try_files ... /index.html), validated with nginx -t, applied with nginx -s reload.
Symptomsβ
Two presentations, one root cause:
Presentation 1: plain 404
GET https://example.com/api/agents β 404 Not Found
Presentation 2: index.html comes back (sneakier)
GET /api/agents β 200 OK, but the body is <!DOCTYPE html>...
Frontend fails with: Unexpected token '<' in JSON
The second one is the SPA fallback catching the request: Nginx finds no static file at /api/agents, and try_files $uri /index.html bounces it to the frontend entry page. The status code is even a green 200 β it only blows up when the frontend parses the body, which makes it look like "the request succeeded".
Root Cause: proxy Is a Dev-Server Runtime Featureβ
This block in vite.config.ts:
// vite.config.ts
export default defineConfig({
server: {
proxy: {
'/api': 'http://127.0.0.1:3000',
},
},
})
works in exactly one context: the dev server started by npm run dev. That dev server is a Node process, and server.proxy is forwarding middleware inside it β the browser requests localhost:5173/api/agents, the dev server passes it to 127.0.0.1:3000, and brings the response back.
After npm run build, the output is a pile of static files in dist/, and the browser fetches them straight from Nginx β there is no Vite process anywhere in that chain, and the server.proxy config stays behind in vite.config.ts; it never ships with the build.
So in production the request path is: browser β Nginx β ?. Nginx matches locations; with no /api rule, no static file exists at /api/agents, so it either 404s or falls through to index.html via the SPA fallback β the two presentations, explained.
The Fix: Let Nginx Own the Production Proxyβ
Step 1: add a proxying location to the server block.
server {
listen 80;
server_name example.com;
root /var/www/app/dist;
# API proxy: prefix match, wins over the SPA fallback
location /api {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SPA fallback: everything else goes to the frontend entry
location / {
try_files $uri $uri/ /index.html;
}
}
Two points:
proxy_passpoints at the real backend address and port (swap the example's 3000 for yours);/api-prefixed requests are forwarded as-is, so backend routes must carry the/apiprefix- Nginx prefix locations match by longest prefix, so
/apinaturally beats/β no ordering tricks needed; just make sure your longest API common prefix is covered
Step 2: verify, then serve traffic.
nginx -t # syntax check
nginx -s reload # graceful reload
# Smoke test: should return backend data, not HTML
curl -i https://example.com/api/health
JSON with Content-Type: application/json means the proxy is live; text/html means the fallback is still catching requests.
A side benefit: once both apps share a domain through the reverse proxy, the CORS problem you dodged in dev with Vite proxy disappears in production too β no CORS headers to configure.
This post and Frontend Deploy Looks Outdated? Nginx Cache and Build Output Checks are companion reads: that one covers "the deployed code is stale", this one covers "the API has no proxy layer in production". Check both and deployment-related 404s are pretty much exhausted.
Watch out
The location path must match the backend route prefix exactly. If backend routes live at /api/agents but Nginx proxies /apis, or proxy_pass ends with a stray / (which triggers path rewriting β /api/agents arrives at the backend as /agents), you get a fresh new 404. After any change, curl -i one real endpoint and check the response headers before sending traffic.
FAQβ
Does Vite proxy work in production?β
No. server.proxy is a runtime middleware of the dev server β it exists when you run npm run dev. A production build produces plain static files served by Nginx (or any web server), with no Vite process and no proxy layer. The production equivalent is an Nginx location block with proxy_pass, which typically adds 2 proxy_set_header lines and a reload.
Why is the Vite proxy not working after build?β
Because the proxy config never ships with the build. After deploying, /api requests hit Nginx directly; with no /api location rule they either 404 or get caught by the SPA fallback (try_files ... /index.html), returning HTML with a 200 status that breaks JSON parsing on the frontend. Add an /api proxy location and reload Nginx.
How do I route API requests through Nginx for a Vite app?β
Inside the server block add: location /api { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } β then nginx -t and nginx -s reload. Verify with curl -i https://example.com/api/health: a JSON content type means the proxy is live, text/html means the SPA fallback is still catching it.
CCLEE
Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.
Work with me