# 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. ```bash npm run preview ``` ### `serve` (or any other static server) The `-s` / `--single` flag enables the fallback: ```bash 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: ```json { "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 ```bash npm run build:analyze # opens dist/stats.html automatically (Windows: `start`) ``` [`rollup-plugin-visualizer`](https://github.com/btd/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`: ```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: ```ts // 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()`: ```ts // 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`](https://rollupjs.org/configuration-options/#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](https://github.com/TanStack/router/blob/main/packages/router-core/package.json) alongside `dist/`: ```json "files": ["dist", "src", "skills", "bin", "!skills/_artifacts"] ``` Additionally, every TanStack package declares [`"sideEffects": false`](https://github.com/TanStack/router/blob/main/packages/router-core/package.json) in its `package.json`, which is the official signal to bundlers — per the [webpack tree-shaking spec](https://webpack.js.org/guides/tree-shaking/#mark-the-file-as-side-effect-free) 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: 1. **Source is present in the npm package** ```bash ls node_modules/@tanstack/router-core/src/ ``` Check the package's [`"files"` field](https://docs.npmjs.com/cli/v10/configuring-npm/package-json#files) — if `"src"` is not listed, the directory is absent from the installed package and there is nothing to alias. 2. **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) ``` 3. **Subpath exports exist and have correct variants** Some packages export environment-specific files via the [Node.js package subpath exports](https://nodejs.org/api/packages.html#subpath-exports) map (e.g. `browser` vs `node` vs `development` conditions). Without explicit aliases Vite appends the subpath to the file alias and produces an invalid path like `src/index.ts/isServer`. Fix: declare subpath aliases **before** the base-package alias ([Vite resolves aliases in array order](https://vitejs.dev/config/shared-options.html#resolve-alias)): ```ts { 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.ts` variant for browser builds (`browser` / `import` condition in the exports map — confirmed in the [official exports map](https://github.com/TanStack/router/blob/main/packages/router-core/package.json)). 4. **Exclude from `optimizeDeps` — but skip packages with CJS transitive deps** Without `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`](https://vitejs.dev/config/dep-optimization-options.html#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-store` is the exception here.** Its source file `src/useStore.ts` imports `use-sync-external-store/shim/with-selector` which 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.js` imports `use-sync-external-store/shim/with-selector` — that is the string Vite must match to serve the pre-bundled ESM wrapper: ```ts 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 ESM `exports` map, uses `module.exports`). If so, add the exact import path to `optimizeDeps.include`. 5. **Do not set `treeshake.moduleSideEffects: false` globally** Setting it globally tells Rollup every module is pure and eliminates any module whose exports are unused. App entry points (`main.tsx` calls `createRoot().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`](https://rollupjs.org/configuration-options/#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](https://rolldown.rs) 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: ```json "devDependencies": { "vite": "npm:rolldown-vite@7.3.1" } ``` > **Pin the version.** `rolldown-vite` is experimental and may introduce > breaking changes in patch releases. > Do not use `@latest` in 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.). - `manualChunks` is deprecated in favour of Rolldown's `advancedChunks`. - The `transformWithEsbuild` Vite helper now requires `esbuild` installed separately (Rolldown uses Oxc internally). See the [official rolldown-vite docs](https://vite.dev/guide/rolldown) for the full compatibility matrix. --- ## Build targets and module format ```ts 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: ```css /* src/styles.css */ @import "tailwindcss"; ``` Plugin in `vite.config.ts`: ```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.