Vite in Depth: Configuring the Dev Server and Build

Master vite.config.ts: plugins, aliases, environment variables and modes, asset handling, TypeScript integration, build configuration, the dev server, proxying, and dependency pre-optimization.

Vite is the build tool and dev server this course's React projects run on. Its defaults are good enough to get started with zero configuration, but every real project eventually needs to customize something — an alias, a proxy for API calls during development, environment-specific variables, or build output settings. All of that lives in one file: vite.config.ts. This lesson walks through that file section by section, explaining not just the syntax but why each option exists and when you'd reach for it.

Anatomy of vite.config.ts

vite.config.ts exports a configuration object (or a function returning one) via defineConfig, a helper that exists purely to give you TypeScript autocomplete and type checking on the config shape — it doesn't change behavior at runtime. The file is executed by Node (or a Node-compatible runtime) when you run vite, vite build, or vite preview, so it can use environment variables, read files, and import Node built-ins, unlike your application source code.

The function form, defineConfig(({ command, mode }) => ({ ... })), is useful when configuration needs to differ between vite (the dev server, command === 'serve') and vite build (command === 'build'), or between modes like development, production, or a custom 'staging' mode.

Plugins: extending what Vite understands

Vite's core only handles what browsers and esbuild natively understand well: JS/TS, JSX, CSS, JSON, and static assets. Everything else — React's Fast Refresh, SVG-as-component imports, legacy browser support, PWA manifests, bundle visualization — comes through plugins listed in the plugins array. Plugins hook into the build pipeline at specific stages (resolving imports, transforming file contents, generating final output) using an interface largely compatible with Rollup's plugin API, since Vite uses Rollup for production builds.

The single most important plugin in a React project is @vitejs/plugin-react (or @vitejs/plugin-react-swc for a faster Rust/SWC-based variant), which enables JSX transformation and Fast Refresh — the mechanism that updates a component in the browser after you save a file, without losing component state.

Aliases and module resolution

The resolve.alias option remaps import specifiers, so import Button from "@/components/Button" resolves without a long relative path like ../../../components/Button. Aliases are covered in depth in the next lesson, but they're configured here, in resolve.alias, and must be mirrored in tsconfig.json's compilerOptions.paths for TypeScript and your editor to understand them too.

Environment variables and modes

Vite loads environment variables from .env files (.env, .env.local, .env.[mode], .env.[mode].local) using a library called dotenv, and exposes them to your client code through import.meta.env. For security, only variables prefixed with VITE_ are exposed to client-side code by default — anything else in a .env file stays server/build-side only, preventing accidental leakage of secrets into a bundle that ships to every visitor's browser.

A 'mode' is a named configuration context — development and production are Vite's built-in defaults, but you can define and pass any string, e.g. vite build --mode staging, which then loads .env.staging and makes mode === "staging" available inside defineConfig's function form.

• import.meta.env.MODE — the current mode string. • import.meta.env.DEV / import.meta.env.PROD — booleans for the two most common cases. • import.meta.env.VITE_API_URL — a custom variable, only exposed because it's prefixed with VITE_.

Static assets, public assets, and CSS handling

Files imported directly from your source (import logo from "./logo.png") are treated as modules: Vite resolves them, may inline small files as base64 data URIs (controlled by build.assetsInlineLimit), and otherwise emits them as hashed files in the build output, returning the final URL as the import's value.

Files placed in the public/ directory are handled completely differently: they are copied to the output root as-is, unhashed, and referenced by absolute path (/favicon.ico). Use public/ only for files that must keep an exact, predictable filename — favicons, robots.txt, manifest files — never for anything imported from your component code, since public assets bypass all bundling, hashing, and optimization.

CSS is handled natively: import "./App.css" just works, CSS Modules work automatically for any file named *.module.css, and PostCSS/Tailwind configuration is picked up automatically from standard config files (postcss.config.js, tailwind.config.js) with no extra Vite configuration needed.

TypeScript integration and build configuration

Vite does not type-check your code — it uses esbuild purely to strip types and transpile as fast as possible, ignoring type errors entirely during dev and build. This is a deliberate performance tradeoff. Type safety is enforced separately, typically by running tsc --noEmit (or tsc -b for project references) as part of your build script or in CI, and by your editor's live TypeScript language server. A typical build script is "build": "tsc -b && vite build" — type-check first, then bundle.

The build section of the config controls production output: build.outDir (default dist), build.sourcemap (whether to emit source maps for production), build.target (which JS syntax level to output, e.g. es2020), and build.rollupOptions for advanced control over chunking and entry points, since Vite delegates production bundling to Rollup under the hood.

The dev server and the proxy option

Vite's dev server (vite, i.e. npm run dev) serves your source as native ES modules directly to the browser during development — no bundling happens at all for your own code, which is why cold start is nearly instant regardless of project size. Only when the browser requests a specific file does Vite transform it on the fly. Dependencies from node_modules are the exception: they're pre-bundled ahead of time (see below).

The server.proxy option solves a common development problem: your frontend runs on http://localhost:5173 but your API runs on a different origin, e.g. http://localhost:8080, and calling it directly from the browser triggers CORS errors. Configuring a proxy makes Vite's dev server itself forward matching requests server-to-server, so the browser only ever talks to one origin.

Dependency pre-optimization

Because Vite serves your own source as native ES modules, but many node_modules packages are shipped as hundreds of small CommonJS or ESM files with deep internal import chains, requesting them unbundled would mean hundreds of extra network round-trips just to load one library like lodash-es or react-dom. To avoid this, Vite pre-bundles dependencies using esbuild the first time you run the dev server, converting each into a single flat ESM file cached in node_modules/.vite. This step, called dependency optimization or 'pre-bundling', is why the very first vite startup after installing packages takes a moment longer, and why optimizeDeps.include/optimizeDeps.exclude exist to manually correct edge cases where Vite's automatic dependency discovery misses something.

A realistic, fully annotated vite.config.ts

loadEnv must be called explicitly inside the config function — plain process.env access in vite.config.ts does not automatically include .env file values. resolve.alias here must be mirrored in tsconfig.json's compilerOptions.paths for editor and type-checker support.

import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import path from "node:path";

export default defineConfig(({ mode }) => {
  // Load .env, .env.[mode], etc. into process.env for use inside this config
  const env = loadEnv(mode, process.cwd(), "");

  return {
    plugins: [react()],

    resolve: {
      alias: {
        "@": path.resolve(__dirname, "./src"),
      },
    },

    server: {
      port: 5173,
      proxy: {
        "/api": {
          target: env.VITE_API_URL ?? "http://localhost:8080",
          changeOrigin: true,
          rewrite: (p) => p.replace(/^\/api/, ""),
        },
      },
    },

    build: {
      outDir: "dist",
      sourcemap: mode !== "production",
      target: "es2020",
    },

    optimizeDeps: {
      include: ["react", "react-dom"],
    },
  };
});

Code Examples

The browser only ever sees requests to its own origin (localhost:5173). Vite's dev server intercepts anything matching /api and forwards it to localhost:8080 on the server side, sidestepping CORS entirely — a technique never needed in production, where the frontend and API are typically served from the same domain or the API sets proper CORS headers.

target: "http://localhost:8080", changeOrigin: true, Only variables prefixed with VITE_ are inlined into client-side code at build time. Anything else in.env stays out of the shipped bundle, which is a deliberate security boundary, not an oversight.

export const API_URL = import.meta.env.VITE_API_URL;

Common mistake: Putting a real secret in a VITE_-prefixed variable — it will be embedded in plain text in the shipped JavaScript bundle, visible to anyone who opens DevTools.

// vite.config.ts (relevant excerpt)
server: {
  proxy: {
    "/api": {
      target: "http://localhost:8080",
      changeOrigin: true,
    },
  },
},

// src/api/users.ts — the app just fetches a relative path
export async function getUsers() {
  const res = await fetch("/api/users");
  return res.json();
}
// .env.production
VITE_API_URL=https://api.example.com
DATABASE_PASSWORD=super-secret   // NOT exposed to client code

// src/config.ts
export const API_URL = import.meta.env.VITE_API_URL;
// import.meta.env.DATABASE_PASSWORD is undefined here — by design

Dev server vs. production build data flow

Dev mode transforms files on demand with no bundling of your own source; production build runs the full Rollup pipeline once, ahead of time.

DEV (vite):
    browser ──module req──▶ Vite dev server ──on the fly──▶ transform+serve
                                   │
                                   └── node_modules deps ──▶ pre-bundled (esbuild, cached)

  BUILD (vite build):
    src/**  ──▶ Rollup ──▶ chunks ──▶ minify ──▶ hash ──▶ dist/

Important Rules

Always mirror resolve.alias entries in tsconfig.json's paths — Vite and TypeScript resolve modules independently and must agree.

Only prefix a variable with VITE_ if it is safe to ship to every visitor's browser.

Use server.proxy for development convenience only; production CORS/routing should be solved at the infrastructure level.

Let tsc (or your editor) catch type errors — Vite/esbuild will happily build code with type errors in it.

Where this shows up on real teams

Almost every onboarding friction point on a real React/Vite project traces back to vite.config.ts: a new teammate's API calls fail because the proxy isn't set up on their machine, a staging deploy shows production data because the wrong .env file loaded, or a shared component library import breaks because an alias wasn't mirrored into tsconfig.json. Reading and understanding this one file end to end is one of the fastest ways to get productive on an unfamiliar codebase.

Assuming a type error will fail vite build.

Run tsc -b (or tsc --noEmit) as a separate check, e.g. "build": "tsc -b && vite build".

esbuild strips types without checking them, purely for speed; a build can "succeed" and ship broken code if type-checking isn't run as its own step.

Importing a large local image from src/ and expecting an exact, unhashed filename.

Put files that need a stable, predictable filename (favicon, robots.txt, manifest.json) in public/ instead.

Assets imported from src/ get hashed filenames for cache-busting; only public/ assets are copied through untouched.

Why does Vite's dev server start almost instantly even on a large project? A. It bundles the entire app in the background before responding B. It serves your own source files as native ES modules on demand, transforming only what the browser actually requests C. It caches the entire production bundle from the last build D. It skips TypeScript entirely, even at build time

B. It serves your own source files as native ES modules on demand, transforming only what the browser actually requests — Unlike a traditional bundler that must process the whole app upfront, Vite's dev server leans on native browser ES module loading and only transforms files as they're requested.

What determines whether an environment variable is exposed to client-side code? A. Whether it's defined in .env vs .env.local B. Whether it starts with the VITE_ prefix C. Whether NODE_ENV is set to production D. Whether it's referenced in vite.config.ts

B. Whether it starts with the VITE_ prefix — Only VITE_-prefixed variables are inlined into client bundles by default, which prevents accidental leakage of server-only secrets into shipped code.

What is the purpose of server.proxy in vite.config.ts? A. To speed up production builds B. To forward matching development requests to another origin server-side, avoiding browser CORS issues C. To minify assets during development D. To generate source maps

B. To forward matching development requests to another origin server-side, avoiding browser CORS issues — server.proxy makes the dev server forward requests (e.g. /api/*) to a backend running elsewhere, so the browser only ever talks to one origin during local development.

Configure a Vite project so that requests to /api/* during development are proxied to a backend running on port 4000, and so that a VITE_API_URL environment variable is used everywhere else (including in production) instead of a hardcoded URL.

Add server: { proxy: { "/api": { target: "http://localhost:4000", changeOrigin: true } } } to vite.config.ts, define VITE_API_URL in .env/.env.production, and centralize export const API_URL = import.meta.env.VITE_API_URL in one config module.

Use server.proxy for the dev-only forwarding, and import.meta.env.VITE_API_URL in a single shared src/config.ts that the rest of the app imports from.