| | |

Vite 19 ⚡ Proxy Configuration — Forwarding API requests during development

⏱️ Estimated read: 14 min | |

The Vite dev server proxy exists to solve a problem that is created by the browser’s same-origin policy and the architecture of local development. The frontend runs on localhost:5173. The backend API runs on localhost:3000 or localhost:8000 or some other port. The browser blocks the request because the port is different, and the developer sees a CORS error. The proxy sidesteps the entire problem by making the request server-side: the browser calls /api/users on the Vite dev server, Vite forwards it to the backend, and the response comes back through the same origin. The browser never sees the backend’s port, so CORS never applies .

The configuration lives under server.proxy in vite.config.ts. The key is the path prefix that the frontend requests, and the value is either a target URL string or an options object with target, changeOrigin, rewrite, ws, and configure . The proxy uses http-proxy-3 under the hood, and the full option set is available for cases that need header manipulation, WebSocket upgrade handling, or event logging . The options are not complex, but the failure modes are specific: a misplaced changeOrigin produces a silent 404, a missing rewrite sends the wrong path to the backend, and a target read from an unloaded environment variable produces a “Must set target or forward” error that does not mention the actual cause .

Key point: The server.proxy configuration forwards matching requests from the Vite dev server to a backend target. changeOrigin: true rewrites the Host header to match the target, which is required when the backend uses virtual hosting. rewrite transforms the path before forwarding, which is required when the backend does not expect the proxy prefix.


Why the proxy configuration is necessary

The CORS problem. The browser enforces the same-origin policy. A request from http://localhost:5173 to http://localhost:3000/api is a cross-origin request. The browser sends it only if the backend responds with the appropriate Access-Control-Allow-Origin header. In development, the backend often does not have CORS configured, or it is configured for a different origin. The proxy eliminates the cross-origin request entirely: the browser calls /api on the same origin it loaded the page from, and the Vite dev server makes the backend request server-side, where CORS does not apply .

The environment drift problem. The backend port changes between developers, between branches, and between the local machine and the CI environment. Hardcoding the backend URL in the frontend source means editing the source when the port changes. The proxy configuration is the single place where the backend target is declared. The frontend code calls /api/users regardless of where the backend runs; the proxy configuration decides where that request actually goes .

The path mismatch problem. The frontend requests /api/users. The backend endpoint is /users. Without a rewrite, the proxy forwards /api/users to the backend, which returns a 404 because the backend has no /api prefix. The rewrite option strips the prefix before forwarding: path.replace(/^\/api/, '') turns /api/users into /users, and the backend receives the path it expects .

The trade-off. The proxy is a development-only feature. It does not exist in the production build. A frontend that relies on the proxy to strip a prefix or rewrite a path will break in production unless the production host provides the same rewriting logic, or the frontend is configured to call the backend’s actual URL directly. The proxy solves the development-time CORS problem; it does not solve the production-time routing problem.


a. The basic proxy configuration

The simplest configuration forwards every request under a prefix to a target without rewriting the path.

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      // Every request starting with /api is forwarded to the backend.
      '/api': {
        target: 'http://localhost:3000',
        // changeOrigin rewrites the Host header to match the target.
        // Required when the backend uses virtual hosting or checks the Host.
        changeOrigin: true,
      },
    },
  },
});

With this configuration, a request to http://localhost:5173/api/users is forwarded to http://localhost:3000/api/users. The backend receives the /api prefix. If the backend’s endpoint is /api/users, this works. If the backend’s endpoint is /users, the proxy forwards the wrong path and the backend returns a 404.


b. The rewrite option and the path mismatch

The rewrite function transforms the request path before forwarding. It receives the original path and returns the path the backend should receive.

// vite.config.ts — stripping the /api prefix
export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
        // Strip /api from the path before forwarding.
        // /api/users becomes /users.
        rewrite: (path) => path.replace(/^\/api/, ''),
      },
    },
  },
});

The regex /^\/api/ matches the literal prefix /api at the start of the path. The replacement is an empty string, which removes the prefix. A request to /api/users becomes /users, and the backend receives the path it expects . The rewrite function must be defined inside the options object for the specific prefix. Placing changeOrigin or rewrite at the same level as the prefix key instead of inside the nested object produces a configuration error or silently ignored options .


c. The WebSocket and event listener configuration

The proxy supports WebSocket connections and provides a configure callback for accessing the underlying proxy instance. The configure callback is where event listeners are attached for logging, header manipulation, or error handling.

// vite.config.ts — WebSocket and logging
export default defineConfig({
  server: {
    proxy: {
      // WebSocket endpoint
      '/socket.io': {
        target: 'ws://localhost:5174',
        ws: true,
        // rewriteWsOrigin rewrites the origin header for WebSocket requests.
        rewriteWsOrigin: true,
      },
      // HTTP API with logging
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ''),
        // The configure callback receives the proxy instance.
        // Event listeners can be attached here for debugging.
        configure: (proxy) => {
          proxy.on('error', (err) => {
            console.error('Proxy error:', err.message);
          });
          proxy.on('proxyReq', (proxyReq, req) => {
            console.log('Forwarding:', req.method, req.url);
          });
        },
      },
    },
  },
});

The ws: true option enables WebSocket proxying for the matched prefix. The configure callback is useful for diagnosing proxy failures: the error event fires when the target is unreachable or the request fails, and the proxyReq event fires before the request is forwarded . The rewriteWsOrigin option rewrites the Origin header for WebSocket connections, which is required when the WebSocket server validates the origin.


Complete Example Session

// ============================================
// PART 1: THE BASIC HTTP PROXY
// ============================================

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
      },
    },
  },
});

// Browser requests /api/users → Vite forwards to localhost:3000/api/users

// ============================================
// PART 2: THE REWRITE OPTION
// ============================================

// The backend endpoint is /users, not /api/users.
// The rewrite strips the prefix before forwarding.
export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ''),
      },
    },
  },
});

// Browser requests /api/users → Vite forwards to localhost:3000/users

// ============================================
// PART 3: THE ENVIRONMENT VARIABLE TARGET
// ============================================

// vite.config.ts — reading the target from an environment variable
import { defineConfig, loadEnv } from 'vite';

export default defineConfig(({ mode }) => {
  // loadEnv reads .env files. Without it, process.env
  // does not contain the .env variables in the config file.
  const env = loadEnv(mode, process.cwd(), '');

  return {
    server: {
      proxy: {
        '/api': {
          // If PUBLIC_API_URL is undefined, the target is undefined,
          // and the proxy throws "Must set target or forward".
          target: env.PUBLIC_API_URL,
          changeOrigin: true,
        },
      },
    },
  };
});

// .env
// PUBLIC_API_URL=http://localhost:3000

// ============================================
// PART 4: THE WEBSOCKET PROXY
// ============================================

export default defineConfig({
  server: {
    proxy: {
      '/socket.io': {
        target: 'ws://localhost:5174',
        ws: true,
      },
    },
  },
});

// The ws: true option enables WebSocket upgrade proxying.
// Without it, the WebSocket connection fails.

// ============================================
// PART 5: THE CONFIGURE CALLBACK FOR LOGGING
// ============================================

export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        configure: (proxy) => {
          proxy.on('error', (err) => {
            console.error('Proxy error:', err.message);
          });
          proxy.on('proxyReq', (proxyReq, req) => {
            console.log('Forwarding:', req.method, req.url);
          });
          proxy.on('proxyRes', (proxyRes, req) => {
            console.log('Received:', proxyRes.statusCode, req.url);
          });
        },
      },
    },
  },
});

// ============================================
// PART 6: THE REGEXP PREFIX
// ============================================

// A prefix starting with ^ is interpreted as a RegExp.
export default defineConfig({
  server: {
    proxy: {
      // Match /api/v1/*, /api/v2/*, etc.
      '^/api/v[0-9]+/.*': {
        target: 'http://localhost:3000',
        changeOrigin: true,
      },
    },
  },
});

// ============================================
// PART 7: THE PREVIEW SERVER INHERITANCE
// ============================================

// The preview server inherits server.proxy by default.
// If preview.proxy is defined, it REPLACES the server proxy,
// it does not merge with it.
//
// vite.config.ts
export default defineConfig({
  server: {
    proxy: {
      '/api': { target: 'http://localhost:3000', changeOrigin: true },
    },
  },
  preview: {
    // This block replaces the server proxy entirely.
    // The /api proxy is NOT inherited.
    proxy: {
      '/feature-flags': { target: 'http://localhost:4000', changeOrigin: true },
    },
  },
});

// To keep the server proxy in preview, spread it:
const devProxy = {
  '/api': { target: 'http://localhost:3000', changeOrigin: true },
};

export default defineConfig({
  server: { proxy: devProxy },
  preview: {
    proxy: {
      ...devProxy,
      '/feature-flags': { target: 'http://localhost:4000', changeOrigin: true },
    },
  },
});

// ============================================
// PART 8: THE CORS ELIMINATION
// ============================================

// Without the proxy:
// Browser at localhost:5173 calls localhost:3000/api/users
// → Cross-origin request
// → Browser enforces CORS
// → Backend must respond with Access-Control-Allow-Origin
//
// With the proxy:
// Browser at localhost:5173 calls localhost:5173/api/users
// → Same-origin request
// → No CORS enforcement
// → Vite forwards to localhost:3000 server-side

// ============================================
// PART 9: THE SECURE OPTION
// ============================================

// If the target uses a self-signed certificate:
export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'https://localhost:8443',
        changeOrigin: true,
        // secure: false disables certificate verification.
        // Only for development with self-signed certificates.
        secure: false,
      },
    },
  },
});

// ============================================
// PART 10: VERIFICATION CHECKLIST
// ============================================

// [ ] The proxy key matches the frontend request path.
// [ ] The target includes the protocol and port.
// [ ] changeOrigin is set for virtual-hosted backends.
// [ ] The rewrite strips the prefix if the backend does not expect it.
// [ ] WebSocket prefixes have ws: true.
// [ ] Environment variable targets are loaded with loadEnv.
// [ ] The configure callback logs errors for debugging.

The ten parts covered the basic configuration, the rewrite, the environment variable loading, WebSocket support, the configure callback, RegExp matching, the preview inheritance behavior, the CORS elimination, the secure option, and the verification checklist. The through-line is that the proxy is a development-time forwarding rule, and each option addresses a specific mismatch between the frontend’s request and the backend’s expectation.


Quick Reference

Proxy options

OptionTypePurpose
targetstringBackend URL (protocol + host + port)
changeOriginbooleanRewrite Host header to match target
rewrite(path) => stringTransform the request path
wsbooleanEnable WebSocket proxying
securebooleanVerify SSL certificates (default true)
configure(proxy, options) => voidAccess proxy instance for event listeners
rewriteWsOriginbooleanRewrite Origin header for WebSocket

Common configuration patterns

PatternConfigurationResult
Forward with prefixtarget only/api/users → backend/api/users
Strip prefixrewrite: (p) => p.replace(/^\/api/, '')/api/users → backend/users
WebSocketws: trueUpgrade requests forwarded
Self-signed certsecure: falseCertificate verification disabled

Preview inheritance behavior

ConfigurationBehavior
server.proxy onlyPreview inherits the entire server proxy
preview.proxy definedPreview replaces the server proxy entirely
Spread server.proxy into preview.proxyBoth sets of rules apply

Best Practices

✅ Do This:

// Use changeOrigin for virtual-hosted backends
changeOrigin: true                                              // ✅

// Strip the prefix when the backend does not expect it
rewrite: (path) => path.replace(/^\/api/, '')                   // ✅

// Load environment variables with loadEnv in the config
const env = loadEnv(mode, process.cwd(), '');                   // ✅

// Use configure for error logging
configure: (proxy) => proxy.on('error', (e) => console.error(e)) // ✅

// Enable ws for WebSocket prefixes
ws: true                                                        // ✅

❌ Don’t Do This:

// Put changeOrigin outside the nested object
proxy: { '/api': 'http://localhost:3000', changeOrigin: true } // ❌

// Use process.env directly in the config without loadEnv
target: process.env.API_URL // undefined unless loadEnv is used   // ❌

// Forget the rewrite when the backend path differs
// /api/users → backend/api/users (404 if backend has /users)     // ❌

// Define preview.proxy without spreading server.proxy
preview: { proxy: { '/other': {} } } // server proxy is lost     // ❌

Common Pitfalls

PitfallWhy It HappensFix
404 from backendPrefix not strippedAdd rewrite: (p) => p.replace(/^\/api/, '')
“Must set target or forward”Environment variable undefinedUse loadEnv in the config
CORS error persistsRequest not matched by proxy prefixVerify the key matches the request path
WebSocket failsws: true missingAdd ws: true to the WebSocket prefix
Proxy works in dev, fails in previewpreview.proxy replaced server.proxySpread server.proxy into preview.proxy
ChangeOrigin silently ignoredPlaced outside nested optionsMove inside the prefix’s options object

Real-World Examples

1. Basic API proxy

server: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } } }

2. Strip prefix

rewrite: (path) => path.replace(/^\/api/, '')

3. Environment variable target

const env = loadEnv(mode, process.cwd(), '');
target: env.PUBLIC_API_URL

4. WebSocket proxy

'/socket.io': { target: 'ws://localhost:5174', ws: true }

5. Self-signed certificate

secure: false

6. Error logging

configure: (proxy) => proxy.on('error', (err) => console.error(err))

7. RegExp prefix

'^/api/v[0-9]+/.*': { target: 'http://localhost:3000' }

8. Preview proxy spread

preview: { proxy: { ...devProxy, '/flags': { target: '...' } } }

9. Production target override

target: process.env.NODE_ENV === 'production' ? 'https://api.example.com' : 'http://localhost:3000'

10. Request logging

proxy.on('proxyReq', (proxyReq, req) => console.log(req.method, req.url))

Visual

The Proxy Request Flow

┌──────────────────────────────────────────────────────────────┐
│  HOW THE PROXY ELIMINATES CORS                                │
│                                                              │
│  Without Proxy                    With Proxy                 │
│  ┌────────────────────┐          ┌────────────────────┐     │
│  │ Browser            │          │ Browser            │     │
│  │ localhost:5173     │          │ localhost:5173     │     │
│  └─────────┬──────────┘          └─────────┬──────────┘     │
│            │                               │                 │
│            │ fetch('/api/users')           │ fetch('/api/users')
│            │ to localhost:3000             │ to localhost:5173│
│            ▼                               ▼                 │
│  ┌────────────────────┐          ┌────────────────────┐     │
│  │ CORS check         │          │ Vite dev server    │     │
│  │ Cross-origin       │          │ Same-origin        │     │
│  │ Blocked unless     │          │ No CORS check      │     │
│  │ backend allows     │          └─────────┬──────────┘     │
│  └────────────────────┘                    │                 │
│                                             │ server-side     │
│                                             ▼                 │
│                                   ┌────────────────────┐     │
│                                   │ Backend            │     │
│                                   │ localhost:3000     │     │
│                                   └────────────────────┘     │
│                                                              │
│  The browser only sees localhost:5173.                       │
│  The backend request is made by Vite, not the browser.      │
└──────────────────────────────────────────────────────────────┘

The Rewrite Decision

┌──────────────────────────────────────────────────────────────┐
│  DOES THE BACKEND EXPECT THE PREFIX?                          │
│                                                              │
│  Backend endpoint: /api/users                                │
│  ┌────────────────────┐                                      │
│  │ No rewrite needed  │                                      │
│  │                    │                                      │
│  │ /api/users →      │                                      │
│  │ backend/api/users  │                                      │
│  └────────────────────┘                                      │
│                                                              │
│  Backend endpoint: /users                                    │
│  ┌────────────────────┐                                      │
│  │ rewrite needed     │                                      │
│  │                    │                                      │
│  │ /api/users →      │                                      │
│  │ backend/users      │                                      │
│  └────────────────────┘                                      │
│                                                              │
│  The frontend always calls /api/users.                       │
│  The proxy decides whether the backend sees the prefix.      │
└──────────────────────────────────────────────────────────────┘

Preview Proxy Inheritance

┌──────────────────────────────────────────────────────────────┐
│  server.proxy AND preview.proxy                              │
│                                                              │
│  server: { proxy: { '/api': {...} } }                        │
│  preview: { /* no proxy */ }                                 │
│                                                              │
│  Result: preview inherits server proxy.                      │
│  /api works in both dev and preview.                         │
│                                                              │
│  server: { proxy: { '/api': {...} } }                        │
│  preview: { proxy: { '/flags': {...} } }                     │
│                                                              │
│  Result: preview REPLACES server proxy.                      │
│  /api does NOT work in preview.                              │
│  /flags works in preview.                                    │
│                                                              │
│  Fix: spread server proxy into preview proxy.                │
│  preview: { proxy: { ...devProxy, '/flags': {...} } }        │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Configuration locationserver.proxy in vite.config.ts
TargetBackend URL with protocol and port
changeOriginRewrites Host header; required for virtual hosting
rewriteStrips or transforms the path prefix
wsEnables WebSocket proxying
Environment variablesRequire loadEnv in the config
Preview inheritancepreview.proxy replaces server.proxy entirely
Error loggingconfigure callback with proxy.on('error')

Key takeaways:

  • The proxy eliminates CORS by making the request same-origin. The browser calls the Vite dev server; Vite calls the backend server-side. The browser never sees the backend’s port, so the same-origin policy does not apply .
  • changeOrigin: true is required for virtual-hosted backends. It rewrites the Host header to match the target. Without it, the backend may reject the request or route it incorrectly .
  • rewrite is required when the backend does not expect the prefix. The frontend calls /api/users; the backend expects /users. The rewrite strips the prefix before forwarding .
  • Environment variable targets need loadEnv. Reading process.env directly in the config file returns undefined because .env files are not loaded into the config’s process environment. loadEnv(mode, process.cwd(), '') reads them explicitly .
  • preview.proxy replaces server.proxy; it does not merge. If the preview server defines any proxy rules, the server proxy is discarded entirely. Spread the server proxy into the preview proxy to keep both .

Remember: The dev server proxy is a forwarding rule that exists only in development. It solves the CORS problem and the path mismatch problem by moving the request to the server side, where the browser’s same-origin policy does not apply. The configuration has three moving parts—the target, the origin header, and the path rewrite—and each one addresses a specific mismatch between what the frontend requests and what the backend expects. The proxy does not exist in production. A frontend that depends on the proxy to rewrite a path or strip a prefix must be configured to call the production API’s actual URL directly, or the production host must provide the same rewriting logic. The proxy is a development convenience, not a production routing layer.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!