← back to section

The browser understands HTML, CSS, and JavaScript — but modern frontend code is written in TypeScript and JSX, split across hundreds of modules, and depends on dozens of libraries. To turn that into something the browser understands, a build step is required.

Why a build step is needed

Without it there are several problems at once:

  • TypeScript and JSX are not understood by the browser — they must be transformed into plain JavaScript.
  • Hundreds of modules with import/export — without bundling the browser makes hundreds of separate HTTP requests, which is slow.
  • Optimisations — minification, tree shaking (removing unused code), hashing file names for long-term caching — do not scale when done by hand.

A bundler handles all of this: it takes an entry point, builds a dependency graph, transforms and combines modules, and produces the final files.

What happens inside a bundler

The bundler starts from the entry point — usually src/main.tsx. It reads its imports, finds each file, reads its imports, and so on. The result is a module graph:

main.tsx
  └─ App.tsx
       ├─ pages/HomePage.tsx
       │    └─ components/Hero.tsx
       └─ pages/ProductPage.tsx
            └─ api/products.ts

From this graph the bundler assembles one or more output files. Along the way it transforms TypeScript to JS, JSX to React.createElement calls, and applies minification and other optimisations.

Tools today

Vite — the standard for new projects. In development mode it does not build a full bundle: the browser receives individual ESM modules transformed via esbuild — startup is instant. In production Vite uses Rollup for an optimised build. To start a new project — npm create vite@latest.

webpack — was the standard for years; found in existing projects and in Create React App. Slower than Vite, but the plugin ecosystem is mature. Starting a new project on webpack is not advisable.

esbuild and Rollup operate as engines under other tools: esbuild — in Vite for dev mode and transformation, Rollup — for prod builds. They are rarely configured directly.

Rspack (Rust port of webpack) and Turbopack (Vercel) — speed-focused alternatives. Relevant for very large monorepos; the difference is imperceptible in a medium-sized project.

Dev mode vs production build

These are two fundamentally different modes that solve different problems.

Dev mode — for iteration speed:

  • HMR (Hot Module Replacement) updates the changed module in the browser without reloading the page; component state is preserved.
  • No minification — code is readable, debugging in DevTools works normally.
  • Source maps enabled by default.

Production build — for the user:

  • Minification reduces size by 3–5×: whitespace is removed, variables are renamed.
  • Hashed file names (app.a8f3c2.js) allow the browser to cache the file indefinitely — when the content changes the hash changes and the browser downloads the new version.
  • Tree shaking and code splitting are active.
vite              # dev server
vite build        # production build into dist/
vite preview      # local preview of dist/ before deploy

Code splitting and dynamic import

By default all application code ends up in a single file. The user downloads the settings page code even if they only visited the home page.

Code splitting breaks the bundle into parts. The most natural boundary is routes:

import { lazy, Suspense } from 'react';

const SettingsPage = lazy(() => import('./pages/SettingsPage'));

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <Routes>
        <Route path="/settings" element={<SettingsPage />} />
      </Routes>
    </Suspense>
  );
}

React.lazy + dynamic import() — the bundler creates a separate chunk for SettingsPage. It is loaded only when the user navigates to /settings. The initial file is smaller — the page opens faster.

Suspense here is a declarative loading boundary: while the chunk is not yet loaded, the user sees <Spinner />. For more on its role in state handling, see "Error handling and Suspense".

Tree shaking

If you import a single function from a large library, tree shaking ensures that only that function ends up in the bundle, not the whole library:

import { format } from 'date-fns'; // only format goes into the bundle

Tree shaking only works with ESM (import/export). CommonJS (require) is opaque to static analysis — the bundler cannot know what from the module is used and includes it entirely. This is the main reason to switch to ESM versions of libraries.

The second trap — side effects. If a module does something when imported (registers global polyfills, adds plugins), the bundler must include it. The library must declare "sideEffects": false in package.json, otherwise tree shaking will not touch it.

Bundle size analysis

If the initial load is slow — look at what is in the bundle. For Vite there is the rollup-plugin-visualizer plugin:

npm install -D rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';

export default {
  plugins: [visualizer({ open: true })],
};

After vite build a module map with a visual size breakdown opens. Typical causes of a heavy bundle:

  • Heavy dependencies — moment.js with locales (~2 MB), lodash in full. Replace with date-fns and individual lodash-es functions.
  • Duplicates — one library pulled in another version of the same dependency. Resolved with npm dedupe or resolve.alias in the Vite config.
  • Missing code splitting — all code in one file.

Environment variables and modes

Vite reads .env files and exposes variables via import.meta.env. Only variables prefixed with VITE_ are included in the bundle:

# .env.production
VITE_API_URL=https://api.example.com
const apiUrl = import.meta.env.VITE_API_URL;

Variables without the VITE_ prefix are not accessible in client code. This protects against secrets accidentally ending up in the browser bundle.

Healthy build checklist

  • Production build passes without warnings — Rollup and TypeScript warnings are signals, not noise.
  • Initial chunk size — up to 200 KB gzip for a typical application; if more, check with visualizer.
  • Code splitting by routes — every large route in its own chunk via React.lazy.
  • Source maps uploaded to monitoring — without them the error stack in production is unreadable.
  • ESM dependencies, not CommonJS — tree shaking only works on ESM.
  • .env files in .gitignore, only VITE_ variables in code — secrets do not reach the repository.

In short

  • A bundler transforms TypeScript/JSX, builds a module graph, and produces optimised files for the browser.
  • Vite — the standard for new projects: instant dev server on ESM + optimised prod build via Rollup.
  • Dev mode and production solve different problems: HMR and readable code vs minification and file hashing.
  • React.lazy + dynamic import() — route-level code splitting with no extra configuration.
  • Tree shaking removes unused code, but only from ESM libraries; CommonJS is included in full.
  • Visualizer shows what is taking up space; common culprits are heavy dependencies and duplicates.
  • Only VITE_ variables end up in client code — secrets without this prefix will not leak.
  • Rendering performance — code splitting with React.lazy and component optimisation.
  • Modules and npm — how ES modules and package.json work.
  • TypeScript and Node tooling — tsconfig, linters, and the developer environment.
  • Error handling and Suspense — Suspense as a loading boundary during code splitting.