14 KiB
Build system
This document explains the Vite configuration decisions, the optimizations applied, and the day-to-day scripts for this package.
Scripts
| Command | What it does |
|---|---|
npm run dev |
Vite dev server with HMR |
npm run build |
Type-check (tsc --noEmit) then produce dist/ |
npm run build:analyze |
Same as build, then opens dist/stats.html (bundle treemap) |
npm run preview |
Vite's built-in preview server — SPA-aware, no config needed |
npm run serve |
npx serve dist -s — static server with SPA fallback (see below) |
SPA routing and static servers
TanStack Router (like all client-side routers) ships a single index.html.
Every URL — /examples/search, /app/settings, etc. — is handled by JavaScript
after the browser loads that one file.
What breaks: a plain static file server gets a request for
/examples/search, finds no file at that path, and returns 404 before the JS
ever runs.
The fix: fall back to index.html for every unknown path.
vite preview
Always SPA-aware out of the box. Use this for quick post-build checks.
npm run preview
serve (or any other static server)
The -s / --single flag enables the fallback:
npx serve dist -s
# or
npm run serve
public/serve.json (committed, copied to dist/ on every build) carries the
same config for when serve is invoked without the flag:
{ "rewrites": [{ "source": "/**", "destination": "/index.html" }] }
Other servers
| Server | SPA config |
|---|---|
| nginx | try_files $uri $uri/ /index.html; in the location / block |
| Apache | FallbackResource /index.html in .htaccess |
| Caddy | try_files {path} /index.html |
| Express | app.get('*', (_, res) => res.sendFile('dist/index.html')) |
Bundle analysis
npm run build:analyze
# opens dist/stats.html automatically (Windows: `start`)
rollup-plugin-visualizer
writes dist/stats.html — an interactive treemap of every module in the bundle,
with rendered, gzip, and brotli sizes.
Config in vite.config.ts:
visualizer({
filename: "dist/stats.html",
gzipSize: true,
brotliSize: true,
template: "treemap", // alternatives: sunburst, network, raw-data, list
})
Optimizations
All three optimizations are cumulative and independent — they can be applied separately to any Vite + React project.
Baseline
Fresh scaffold with react-dom, Zod v3, and pre-bundled TanStack Router:
index.js 299.6 kB │ gzip: 90.5 kB
1. Preact instead of react-dom
react-dom was ~133 kB rendered / 42 kB gzip. Preact ships the same React 18
API in ~10 kB gzip via its compatibility layer (preact/compat).
No application code changes — only Vite aliases redirect the imports:
// vite.config.ts
{ find: "react-dom/client", replacement: "preact/compat/client" },
{ find: "react-dom", replacement: "preact/compat" },
{ find: "react", replacement: "preact/compat" },
The @preact/preset-vite plugin handles JSX transform and prefresh (HMR).
preact itself is listed as a dependency alongside the nominal react /
react-dom stubs (kept to satisfy peer-dep requirements from other packages).
Saving: ~36 kB gzip
2. zod/mini instead of zod
Full Zod ships ~115 kB rendered / 15 kB gzip for Zod v3. Zod v4's zod/mini
sub-package drops the full schema builder for a tree-shakeable API, landing at
~5 kB gzip for our usage.
One API difference to be aware of: default is a reserved JavaScript keyword
so zod/mini exposes it as z._default():
// searchSchema.ts
import { z } from "zod/mini";
export const migrationSearchSchema = z.object({
view: z.optional(z._default(z.enum(["list", "grid"]), "list")),
q: z.optional(z.string()),
});
Saving: ~10 kB gzip
3. Mounting library TypeScript source
The largest remaining chunk was TanStack Router at ~30 kB gzip. Its npm package
ships a single pre-bundled ESM file (dist/esm/index.js) per package. Even
though Rollup tree-shakes at module-file boundaries, everything inside that one
big file is opaque — unused exports of router.ts, link.ts, etc. survive.
(Rollup docs — treeshake.moduleSideEffects:
"code from imported modules will only be retained if at least one exported
value is used" — but that still requires Rollup to see individual modules, not
a single pre-bundled blob.)
The insight: all five TanStack packages also ship their raw TypeScript source
in src/. This is not accidental — src/ is in the package's
published "files" list
alongside dist/:
"files": ["dist", "src", "skills", "bin", "!skills/_artifacts"]
Additionally, every TanStack package declares
"sideEffects": false
in its package.json, which is the official signal to bundlers — per the
webpack tree-shaking spec
adopted by Rollup — that every module in the package is pure and can be pruned
if its exports are unused.
Pointing Vite's alias at src/index.ts gives Rollup 194 individual source
modules to work with instead of 5 pre-bundled blobs — function-level
tree-shaking becomes possible, and the "sideEffects": false declaration is
now honoured at source granularity.
Prerequisite checks
Before applying this technique to any library, verify:
-
Source is present in the npm package
ls node_modules/@tanstack/router-core/src/Check the package's
"files"field — if"src"is not listed, the directory is absent from the installed package and there is nothing to alias. -
External deps inside the source are resolvable
Scan for non-relative imports in
src/. For TanStack the graph is closed:@tanstack/react-router/src → @tanstack/router-core/src (aliased) → @tanstack/history/src (aliased) → @tanstack/react-store/src (aliased) → react / react-dom (already → preact/compat) -
Subpath exports exist and have correct variants
Some packages export environment-specific files via the Node.js package subpath exports map (e.g.
browservsnodevsdevelopmentconditions). Without explicit aliases Vite appends the subpath to the file alias and produces an invalid path likesrc/index.ts/isServer. Fix: declare subpath aliases before the base-package alias (Vite resolves aliases in array order):{ find: "@tanstack/router-core/isServer", replacement: nm("@tanstack/router-core/src/isServer/client.ts") }, { find: "@tanstack/router-core/scroll-restoration-script", replacement: nm("@tanstack/router-core/src/scroll-restoration-script/client.ts") }, { find: "@tanstack/router-core", replacement: nm("@tanstack/router-core/src/index.ts") },Use the
client.tsvariant for browser builds (browser/importcondition in the exports map — confirmed in the official exports map). -
Exclude from
optimizeDeps— but skip packages with CJS transitive depsWithout
exclude, Vite's dev pre-bundler re-bundles the source into a single opaque chunk and defeats the purpose in dev mode. (Vite docs —optimizeDeps.exclude: "Dependencies to exclude from pre-bundling.")The Vite docs warn:
"CommonJS dependencies should not be excluded from optimization. If an ESM dependency is excluded from optimization, but has a nested CommonJS dependency, the CommonJS dependency should be added to
optimizeDeps.include."@tanstack/react-storeis the exception here. Its source filesrc/useStore.tsimportsuse-sync-external-store/shim/with-selectorwhich is a CJS-only file (module.exports = ...). If that package is excluded from optimizeDeps, Vite serves the raw CJS as ESM and the named import breaks at runtime in dev:Uncaught SyntaxError: The requested module '.../use-sync-external-store/shim/with-selector.js' does not provide an export named 'useSyncExternalStoreWithSelector'The fix requires including the exact import subpath, not the package root.
@tanstack/react-store/dist/esm/useStore.jsimportsuse-sync-external-store/shim/with-selector— that is the string Vite must match to serve the pre-bundled ESM wrapper:optimizeDeps: { exclude: ["@tanstack/router-core", "@tanstack/react-router", "@tanstack/history", "@tanstack/store"], include: ["use-sync-external-store/shim/with-selector"], }Including
"use-sync-external-store"(the root) does not fix it — Vite pre-bundles at subpath granularity and the pre-bundled root is never served for the subpath import.General rule: before source-aliasing a package, scan its
src/for non-relative imports and check whether any of those are CJS-only (no"type": "module", no ESMexportsmap, usesmodule.exports). If so, add the exact import path tooptimizeDeps.include. -
Do not set
treeshake.moduleSideEffects: falsegloballySetting it globally tells Rollup every module is pure and eliminates any module whose exports are unused. App entry points (
main.tsxcallscreateRoot().render()— a side effect with no importer) are silently dropped. Keep the default (true) or scope the override to known-pure library packages only. (Rollup docs —treeshake.moduleSideEffects)
Saving: ~10 kB gzip (on top of the Preact and zod/mini savings)
Combined result
| Stage | Rendered | Gzip |
|---|---|---|
| Baseline (react-dom, zod v3, pre-bundled) | 299.6 kB | 90.5 kB |
| + Preact | ~210 kB | ~54 kB |
| + zod/mini | ~175 kB | ~46 kB |
| + TanStack source aliases | 139.9 kB | 46.4 kB |
| Total reduction | −53 % | −49 % |
The gzip delta for the source-alias step is small because gzip compresses repeated patterns well — the visible win is in the raw rendered size (−35 kB) and in better code-splitting potential, since Rollup can now split individual TanStack modules across async chunks.
rolldown-vite
Rolldown is a Rust-powered bundler designed as a drop-in
replacement for Rollup. Vite is migrating to it officially; in the meantime it
is available as the separate package rolldown-vite.
Installing
Use the npm: package alias so vite resolves to rolldown-vite everywhere —
plugins and scripts continue to import from 'vite' unchanged:
"devDependencies": {
"vite": "npm:rolldown-vite@7.3.1"
}
Pin the version.
rolldown-viteis experimental and may introduce breaking changes in patch releases. Do not use@latestin committed config.
Do not use "overrides" alongside a direct vite devDependency — npm
rejects the conflict.
What changes
| Area | Before (Rollup/esbuild) | After (Rolldown) |
|---|---|---|
| Production bundler | Rollup | Rolldown (Rust) |
| Dep pre-bundler | esbuild | Rolldown |
| CJS interop | @rollup/plugin-commonjs |
Rolldown native |
| JS minifier | esbuild | Oxc |
| CSS minifier | esbuild | Lightning CSS |
Config notes
rollupOptions.output.format is a Rollup-only option not recognised by
Rolldown — remove it. Rolldown outputs ES modules by default, so no
replacement is needed.
Benchmark
| Build time | Main JS gzip | |
|---|---|---|
| Vite 5 + Rollup | ~1100 ms | 46.4 kB |
| rolldown-vite 7 | 331 ms | 45.8 kB |
3× faster build, marginally smaller output. Dev server cold-start also drops from ~730 ms to ~557 ms.
Known limitations
- Some Rollup output options produce validation warnings (unknown keys).
Remove options that Rolldown handles by default (
format: "es", etc.). manualChunksis deprecated in favour of Rolldown'sadvancedChunks.- The
transformWithEsbuildVite helper now requiresesbuildinstalled separately (Rolldown uses Oxc internally).
See the official rolldown-vite docs for the full compatibility matrix.
Build targets and module format
build: {
target: "esnext", // no transpilation downgrade, modern browsers only
modulePreload: { polyfill: false }, // drop the ~1.6 kB modulepreload polyfill
// format defaults to "es" in rolldown-vite — no rollupOptions needed
}
target: "esnext" means no legacy syntax transforms and native dynamic
import() for code splitting.
modulePreload: { polyfill: false } removes the small polyfill irrelevant for
modern browsers.
Tailwind CSS v4
Tailwind v4 ships a dedicated Vite plugin that runs as a PostCSS-free pipeline,
replacing the @tailwindcss/postcss approach from v3. The import in CSS is
also simplified:
/* src/styles.css */
@import "tailwindcss";
Plugin in vite.config.ts:
import tailwindcss from "@tailwindcss/vite";
// …
plugins: [tailwindcss(), preact(), visualizer(…)]
The Tailwind plugin must be listed first so it processes CSS before the JSX transform runs.