Reverse Proxy Pattern for React + Vite

At some point in almost every React + Vite project, someone drops the backend URL into a VITE_API_URL env var and moves on. It works, the demo passes, nobody thinks about it again. Then a few months later you're staring at your own production bundle in DevTools and your internal API host is sitting right there in plain text for anyone to read.
There's a cleaner way to do this, and it's the approach we standardize on. The frontend only ever calls same-origin relative paths under a single prefix: /api. The real backend, storage, and WebSocket hosts stay in a proxy layer (Vite's dev server while you're developing, nginx or Caddy or a CDN in production) and never make it into the JS that ships to the browser. This post walks through how to set that up and the mistakes to watch for.
The Problem
Putting real backend hostnames in VITE_* env vars and reading them from src/ is a bad habit, both for security and for ops.
Anything you reference through import.meta.env.VITE_* inside src/ gets baked into the production bundle at build time. This isn't a runtime lookup. Vite replaces it with the literal string, and that string ships in the JS the browser downloads. Open DevTools, check the Network or Sources tab, and you can read it.
const api = import.meta.env.VITE_API_URL; // "https://api.internal.acme.com" ends up in the bundle
One line like that and you've got three problems:
- Your infra topology leaks. API hosts, bucket names, and WS endpoints are now public.
- CORS gets in the way. Cross-origin calls trigger preflights and push you toward permissive policies on the upstream.
- The build is welded to specific hosts. Want to move a backend? You're rebuilding and redeploying the frontend, because the host is frozen into the artifact.
VITE_* values are not secrets. Anything read in client code is public the moment you build. Never put a real backend host, bucket name, or internal endpoint where src/ can read it.
The Pattern: One Same-Origin Prefix
The frontend only knows about relative paths under /api. A proxy maps that prefix to the real host.
┌──→ REST endpoints
Browser ──→ /api/* ──→ reverse proxy ──→ API ├──→ WebSocket (upgrade)
(same origin) └──→ object storage (server-side)
- Client code calls
/api/...and nothing else. REST, file assets, and the WebSocket all live under it. - The proxy layer owns the real hostname. Client JS never sees one.
- Because everything is same-origin, there's no CORS to fight. The browser only talks to your domain.
Why one prefix and not three
An earlier version of this pattern used three prefixes: /api for REST, /s3 for assets, /ws for the socket. In practice all three pointed at the same upstream. /s3 was never the bucket (the API reads storage on the client's behalf, see below), and the WebSocket is served by the same application process. So we were maintaining three proxy entries, three env vars, and three nginx blocks that all resolved to one host.
One prefix with ws: true covers it. The upgrade works because Vite (and the http-proxy it wraps) attaches the upgrade handler per proxy entry, not per URL path. A single /api entry with ws: true proxies ordinary HTTP requests and WebSocket upgrades through the same rule.
In our projects, REST, WebSocket, and object-storage access are all served by the same backend server. There is no separate socket service and no separate storage endpoint the browser talks to. So one /api prefix is not a simplification we're recommending in the abstract, it's an accurate description of the topology: one upstream, one proxy entry.
Collapse to one prefix only if the WebSocket really is the same upstream as the REST API. If your socket server is a separate service or a separate port, keep it as its own proxy entry with its own target. The point is one entry per upstream, not one entry no matter what.
Dev Setup: vite.config.js
In development, Vite's dev server is the proxy. You configure a single server.proxy entry with ws: true.
The target comes from an env var you read inside vite.config.js, which runs in Node at build and dev time. Here's the part that trips people up:
- A
VITE_*var read invite.config.jsstays in Node. It never reaches the browser bundle. - The same var name read inside
src/does get inlined into the bundle.
Same naming convention, completely different exposure, and the only difference is where you read it. Read proxy targets in vite.config.js, never in src/.
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
// loadEnv reads .env files in Node — these values are NOT bundled.
const env = loadEnv(mode, process.cwd(), '');
return {
plugins: [react()],
server: {
proxy: {
'/api': {
target: env.VITE_PROXY_API_TARGET, // e.g. http://localhost:4000
changeOrigin: true,
ws: true, // handles the WebSocket upgrade on this same entry
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
};
});
That's the whole proxy config. One entry, one env var.
The target stays http://, not ws://, even with ws: true. The proxy switches protocols on upgrade by itself. And Vite's own HMR socket is unaffected: it lives on the dev server root, not under /api.
Client Code Usage
No hostnames, no ports, no env vars. Relative paths only.
import axios from 'axios';
export const api = axios.create({ baseURL: '/api' });
// plain fetch works the same way
async function getOrders() {
const res = await fetch('/api/orders');
return res.json();
}
For WebSockets, build the URL from location.origin so it works on every environment without changes:
const url = new URL('/api/ws', location.origin);
url.protocol = url.protocol.replace('http', 'ws'); // http→ws, https→wss
const socket = new WebSocket(url);
With socket.io, point the client at the same prefix and let it manage the transport:
import { io } from 'socket.io-client';
// same-origin; path must match what the server mounts, after prefix rewriting
const socket = io({ path: '/api/socket.io' });
The client has no idea what sits behind /api, and it doesn't need to.
Production Setup: Real Reverse Proxy Required
Vite's dev proxy does not exist in production. vite build gives you static files in dist/ and nothing else. No server, no proxy. You need a real reverse proxy that mirrors the exact same /api prefix.
You can use nginx, Caddy, Traefik, or CDN path-based routing such as CloudFront behaviors or ALB listener rules. Whatever you pick, the important bit holds: the client code and dev config stay identical across environments. Only the proxy's upstream config changes.
This production proxy config belongs to the DevOps engineer, not the frontend team. As a frontend dev, your job ends at shipping the static dist/ build and keeping the /api prefix stable. DevOps wires that prefix to the real upstream for each environment. The nginx block below is a reference for that handoff.
# Only send "Connection: upgrade" when the client actually asked for it.
# Plain HTTP requests get an empty value, which keeps keepalive intact.
map $http_upgrade $connection_upgrade {
default upgrade;
'' '';
}
server {
listen 80;
server_name example.com;
# Serve the built SPA
root /var/www/app/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html; # SPA fallback
}
# One block for REST, assets, and WebSocket upgrades
location /api/ {
proxy_pass http://api-upstream:4000/; # trailing slash strips /api/
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
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;
# WebSockets are long-lived; don't let the default 60s read timeout kill them
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
The upstream host (api-upstream) comes from per-environment infra config, never from the frontend build.
Connection "upgrade"This is the one real cost of merging the blocks. When /api/ and /ws/ were separate, a literal proxy_set_header Connection "upgrade" only affected socket traffic. In a merged block it applies to every REST request as well, telling nginx to treat plain requests as upgrades and dropping keepalive to the upstream. The map above makes the header conditional, which is what you want. It's also the correct form even in a WS-only block.
A Note on Object Storage (S3 / MinIO)
The frontend never talks to MinIO directly, and it doesn't need a prefix of its own. Assets are just another route on the API, for example /api/files/some/object.png.
The flow is straightforward:
- The frontend requests an asset at
/api/files/some/object.png. - The proxy forwards it to the API.
- The API reads the object from MinIO on the client's behalf and streams it back.
That keeps a clean, readable URL for assets in client code while the bucket name, credentials, and MinIO host all stay on the backend. The browser only ever sees same-origin paths, never a bucket address.
Since the API sits in the middle, there was never a reason for a separate /s3 target: it pointed at the API host anyway. And don't proxy straight to the bucket. If you did, you'd be back to leaking the storage host and managing bucket CORS, which is exactly what this avoids.
If assets are big, add proxy_buffering off; to the /api/ block (or a nested location /api/files/) so nginx streams instead of buffering whole objects to disk before sending them.
Gotchas
- No
ws: truein the Vite proxy entry. The WebSocket upgrade fails quietly. You get no error, the connection just never opens. - Missing
Upgrade/Connectionheaders (andproxy_http_version 1.1) in the nginx/api/block. Same silent failure, this time in prod. Connection "upgrade"hardcoded in a shared block. Breaks upstream keepalive for every REST request. Use themap $http_upgradeform.- Default
proxy_read_timeout. Sockets that are idle for 60s get dropped and the client reconnect-loops. Raise it, or send heartbeats, or both. - A socket path that doesn't survive the rewrite. The Vite entry strips
/api, so a client on/api/socket.ioreaches the server at/socket.io. Make sure the two ends agree, in dev and in prod. - A "secret"
VITE_*var imported insidesrc/instead of only invite.config.js. It gets inlined into the bundle and the whole pattern falls apart. - Thinking
VITE_*vars are secret. They aren't. Anything read in client code is public once you build.VITE_*is a bundling convention, not a secrets mechanism. - A prefix that drifts between environments.
/apihas to be identical in dev (vite.config.js) and prod (nginx or CDN). Let it diverge and you'll be chasing bugs that only show up in one place.
Anti-Pattern: Hardcoded Proxy Target
vite.config.jsNo literal hosts, ports, IPs, or URLs in vite.config.js. Not ever. Every proxy target comes from loadEnv or process.env. A hardcoded value is wrong for somebody, and once it's committed it ships without anyone noticing.
We already have this in our codebase, so it's worth spelling out. Hardcoding the proxy target instead of reading it from loadEnv or process.env causes real pain:
- It breaks for every other dev whose local backend runs on a different host or port.
- It quietly points at the wrong environment when a stale value (say, a staging URL) gets committed and nobody resets it.
- It defeats the whole point of externalizing config. A hardcoded target in a committed file just moves the leak from the JS bundle into source control instead of removing it.
- It makes the config impossible to review at a glance. You can't tell which backend you're hitting without reading the literal string.
proxy: {
'/api': { target: 'http://localhost:4000', changeOrigin: true, ws: true }
}
const env = loadEnv(mode, process.cwd(), '')
proxy: {
'/api': { target: env.VITE_PROXY_API_TARGET, changeOrigin: true, ws: true }
}
Commit a .env.example with a placeholder value so every developer copies it into their own untracked .env instead of hardcoding or guessing:
VITE_PROXY_API_TARGET=http://localhost:4000
One prefix means one variable to document and one to get wrong.
Checklist
- Client code uses relative paths only, all under
/api. No hostnames, ports, or env vars insrc/. - One proxy entry per upstream. If REST, assets, and WS share a host, that's a single
/apientry withws: true. - Real hosts live only in Node-side config:
vite.config.jsfor dev, nginx or Traefik or CDN or infra env for prod. - Read the proxy target from
loadEnvorprocess.envinvite.config.js. Never hardcode, never read it insrc/. - In nginx, set
proxy_http_version 1.1,Upgrade, and a conditionalConnectionviamap $http_upgrade. - Raise
proxy_read_timeoutso long-lived sockets aren't dropped. - Production needs its own reverse proxy.
vite buildonly ships static files. - Keep the
/apiprefix identical across dev and prod, and keep the socket path consistent through any rewrite. - For assets, use a route on the API (
/api/files/...) that reads MinIO server-side. Never point the frontend at the bucket. - Treat every
VITE_*value as public. None of them are secrets. - Commit a
.env.exampleand keep the real.envuntracked.
Cover photo by Rahul Mishra on Unsplash.
