Most teams spend hours wiring up Vite and then ship the default config straight to production. The defaults are sane for development, but they’re not tuned for a real app under real traffic. You get one fat chunk, uncompressed assets, and an SSR setup held together with duct tape.
This article covers the three areas where Vite’s production story has real depth — and real traps. No boilerplate fluff, just the config you’ll actually reach for.
Official repo: https://github.com/vitejs/vite
Why the Default Build Disappoints at Scale
Run vite build on a medium-sized React or Vue app and you get something like this:
dist/assets/index-BkXp3.js 412 kB │ gzip: 134 kB
One chunk. Everything in it. No vendor separation, no route-based splitting, no image compression. The browser has to download, parse, and execute 412 KB of JavaScript before anything interactive happens. On a 4G connection that’s a 2–3 second delay. On a returning visitor it could be cached, but only if nothing changed — and with a single chunk, any code change busts the whole cache.
Vite uses Rollup under the hood for production. That means every Rollup optimization is available to you, and many of them are just a config object away.
Part 1: Chunk Splitting Done Right
Understanding Vite’s Default Behavior
Out of the box, Vite splits on dynamic import() boundaries. If you write const Module = () => import('./HeavyPage'), Vite produces a separate chunk for that module. That’s automatic and you should already be doing it for route-level code splitting.
What it won’t do automatically: split your vendor libraries from your app code, or group libraries sensibly so long-lived dependencies get long-lived cache headers.
Manual Chunks with rollupOptions
The lever is build.rollupOptions.output.manualChunks. Here’s a practical starting point:
// vite.config.js
import { defineConfig } from 'vite'
export default defineConfig({
build: {
rollupOptions: {
output: {
// Deterministic filenames — no content hashes in the chunk name itself,
// hashes go in the file name via [hash]
chunkFileNames: 'assets/js/[name]-[hash].js',
entryFileNames: 'assets/js/[name]-[hash].js',
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
manualChunks(id) {
// Framework runtime — changes rarely, should be cached aggressively
if (id.includes('node_modules/react') ||
id.includes('node_modules/react-dom')) {
return 'vendor-react'
}
// Router — changes occasionally, keep separate
if (id.includes('node_modules/react-router')) {
return 'vendor-router'
}
// Heavy charting / visualisation library
if (id.includes('node_modules/recharts') ||
id.includes('node_modules/d3')) {
return 'vendor-charts'
}
// Everything else in node_modules goes into a shared vendor chunk
if (id.includes('node_modules')) {
return 'vendor'
}
// App code is split by Rollup's automatic dynamic import detection
},
},
},
},
})
After this config, a typical build output looks like:
dist/assets/js/vendor-react-C3kL.js 140 kB
dist/assets/js/vendor-router-D8mQ.js 26 kB
dist/assets/js/vendor-charts-F2xP.js 198 kB
dist/assets/js/vendor-Bk3p.js 60 kB
dist/assets/js/index-Qw9z.js 38 kB
dist/assets/js/Dashboard-Lp1r.js 12 kB
dist/assets/js/Settings-Nm6s.js 8 kB
Now vendor-react stays cached across deploys unless you bump React itself. The user downloads index.js plus whatever page chunks they navigate to, not everything upfront.
The Function vs. Object Form
manualChunks accepts either a function (like above) or a plain object mapping chunk names to module IDs:
manualChunks: {
'vendor-react': ['react', 'react-dom'],
'vendor-router': ['react-router-dom'],
}
The object form is simpler but less flexible — it can’t handle conditional logic or wildcard matching. Stick with the function form for anything beyond two or three entries.
Gotcha: Circular Chunk Dependencies
When you split manually, Rollup has to handle shared modules that are imported by multiple chunks. If chunk A and chunk B both import utils.js, Rollup will either duplicate it or extract it into a third chunk. You can control this with output.experimentalMinChunkSize and by being explicit in manualChunks about where shared utilities live.
Watch your build output for unexpectedly tiny chunks (under 2 KB). A dozen 1 KB chunks is worse than one 12 KB chunk because of HTTP/2 request overhead and header costs. Set build.chunkSizeWarningLimit to surface the large ones:
build: {
chunkSizeWarningLimit: 500, // warn if any chunk exceeds 500 KB
}
Route-Level Splitting with React Router
Pair manualChunks with lazy routes and you get the full picture:
// router.jsx
import { lazy, Suspense } from 'react'
import { createBrowserRouter } from 'react-router-dom'
const Dashboard = lazy(() => import('./pages/Dashboard'))
const Settings = lazy(() => import('./pages/Settings'))
const Reports = lazy(() => import('./pages/Reports'))
export const router = createBrowserRouter([
{ path: '/', element: <Suspense fallback={<Spinner />}><Dashboard /></Suspense> },
{ path: '/settings', element: <Suspense fallback={<Spinner />}><Settings /></Suspense> },
{ path: '/reports', element: <Suspense fallback={<Spinner />}><Reports /></Suspense> },
])
Vite picks up these dynamic imports and creates per-route chunks automatically. The manualChunks config handles vendors; Rollup handles your app routes. They compose cleanly.
Part 2: Asset Optimization
Inline Threshold for Small Files
Every HTTP request has overhead. For tiny SVG icons, small fonts, or a 3 KB background image, it’s cheaper to inline the asset as a base64 data URI than to issue a separate request. Vite inlines assets below build.assetsInlineLimit (default: 4096 bytes):
build: {
assetsInlineLimit: 8192, // inline anything under 8 KB
}
Bump this conservatively. Past about 10–12 KB the base64 overhead (33% size increase) starts hurting more than the request savings help. For large images, never inline.
CSS Code Splitting
CSS splitting is on by default (build.cssCodeSplit: true). Each JS chunk gets a corresponding CSS chunk loaded in parallel. If you’re doing SSR or have a very simple single-page app, disabling it produces one CSS file which is marginally simpler to reason about:
build: {
cssCodeSplit: false, // single CSS bundle — fine for small apps
}
For most apps, leave it enabled. The browser can load JS and CSS in parallel, which matters on the critical path.
Compression: Brotli and Gzip Pre-Compression
Vite does not compress assets by default — that’s left to your web server. But pre-compressing at build time means Nginx or Caddy can serve .br and .gz files directly without CPU overhead per request. Use vite-plugin-compression:
npm install -D vite-plugin-compression
// vite.config.js
import { defineConfig } from 'vite'
import viteCompression from 'vite-plugin-compression'
export default defineConfig({
plugins: [
viteCompression({
algorithm: 'brotliCompress',
ext: '.br',
threshold: 10240, // only compress files > 10 KB
deleteOriginFile: false,
}),
viteCompression({
algorithm: 'gzip',
ext: '.gz',
threshold: 10240,
deleteOriginFile: false,
}),
],
})
Then in Nginx, enable pre-compressed serving:
# nginx.conf
gzip_static on;
brotli_static on; # requires ngx_brotli module
Brotli typically achieves 15–20% better compression than gzip at equivalent CPU cost. Ship both for maximum compatibility.
Image Optimization
Vite doesn’t process images beyond copying them. For a production app you want to run images through a compressor. vite-plugin-imagemin is the go-to, though its dependency tree can be annoying to install:
npm install -D vite-plugin-imagemin
import viteImagemin from 'vite-plugin-imagemin'
export default defineConfig({
plugins: [
viteImagemin({
gifsicle: { optimizationLevel: 3 },
optipng: { optimizationLevel: 5 },
mozjpeg: { quality: 80 },
pngquant: { quality: [0.65, 0.8], speed: 4 },
svgo: {
plugins: [
{ name: 'removeViewBox', active: false },
{ name: 'removeEmptyAttrs', active: false },
],
},
webp: { quality: 75 },
}),
],
})
Gotcha: Image Plugins and CI
vite-plugin-imagemin calls native binaries. In a fresh Docker build or a CI environment that didn’t run npm install correctly, those binaries can silently fail without erroring the build. Always verify image sizes in CI output after adding this plugin. A lightweight alternative: process images separately with sharp or squoosh as a pre-build step and keep Vite’s pipeline clean.
Asset File Naming for Long-Term Caching
The default hash is content-based, which is correct. Make sure your CDN or Nginx config sends aggressive cache headers for hashed assets:
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
immutable tells the browser it never needs to revalidate the file while the cache entry lives. Combined with content hashing, this is safe — when the content changes, the hash changes, the URL changes, and the browser fetches fresh.
Part 3: SSR Patterns
SSR with Vite is genuinely useful and genuinely sharp-edged. The mental model you need: Vite builds two separate bundles. A client build (same as always) and a server build that runs in Node (or an edge runtime). They share source code but not the output.
The Two-Build Setup
// vite.config.js
import { defineConfig } from 'vite'
export default defineConfig(({ isSsrBuild }) => ({
build: {
rollupOptions: {
output: {
// Server build should NOT hash filenames — they're required by path
...(isSsrBuild
? { entryFileNames: '[name].js', chunkFileNames: '[name].js' }
: { entryFileNames: 'assets/js/[name]-[hash].js' }
),
},
},
},
}))
Build commands:
# Client bundle (goes to dist/client)
vite build --outDir dist/client
# Server bundle (goes to dist/server)
vite build --ssr src/entry-server.js --outDir dist/server
Entry Points
You need two entry points. The client entry mounts the app; the server entry exports a render function:
// src/entry-client.js
import { hydrateRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
hydrateRoot(
document.getElementById('root'),
<BrowserRouter><App /></BrowserRouter>
)
// src/entry-server.js
import { renderToString } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom/server'
import App from './App'
export async function render(url, context) {
const html = renderToString(
<StaticRouter location={url} context={context}>
<App />
</StaticRouter>
)
return { html }
}
The SSR Manifest for Asset Preloading
This is the part most tutorials skip. When the server renders a page, it should also emit <link rel="preload"> tags for the chunks that page actually uses — otherwise the browser discovers them only after parsing the HTML, wasting time. Vite generates an SSR manifest for exactly this:
vite build --outDir dist/client --ssrManifest
This produces dist/client/.vite/ssr-manifest.json, mapping module IDs to the assets they need. Use it in your server:
// server.js (Express example)
import express from 'express'
import { readFileSync } from 'fs'
import { render } from './dist/server/entry-server.js'
const app = express()
const template = readFileSync('./dist/client/index.html', 'utf-8')
const manifest = JSON.parse(
readFileSync('./dist/client/.vite/ssr-manifest.json', 'utf-8')
)
app.use(express.static('./dist/client', { index: false }))
app.get('*', async (req, res) => {
const context = {}
const { html: appHtml, modules } = await render(req.url, context)
// Build preload tags from manifest
const preloadLinks = (modules ?? [])
.flatMap(id => manifest[id] ?? [])
.map(file =>
file.endsWith('.js')
? `<link rel="modulepreload" crossorigin href="${file}">`
: `<link rel="preload" href="${file}" as="style">`
)
.join('\n ')
const html = template
.replace('<!--preload-links-->', preloadLinks)
.replace('<!--app-html-->', appHtml)
res.status(context.status ?? 200).set({ 'Content-Type': 'text/html' }).end(html)
})
Without this, the browser stalls waiting for chunk discovery. With it, chunks load in parallel with the HTML parse.
SSR Externals
By default, Vite’s SSR build externalizes all node_modules — they’re not bundled, just require()‘d at runtime. This is what you want for most packages. The problem arises with ESM-only packages that don’t work with CommonJS require().
// vite.config.js
export default defineConfig({
ssr: {
// Force these packages to be bundled (not externalized)
// because they're ESM-only and can't be require()'d in Node's CJS mode
noExternal: ['some-esm-only-package', /^@acme\//],
// Force these to be externalized even if Vite would bundle them
external: ['sharp'], // native modules should always be external
},
})
Gotcha: window and document in SSR
Any code that references window, document, localStorage, or browser-specific APIs will throw on the server. The pattern is to guard these with typeof window !== 'undefined' or to move them into useEffect / onMounted lifecycle hooks that only run client-side. Audit third-party libraries too — some popular ones access window at module evaluation time, which crashes your SSR build immediately.
For libraries that simply don’t support SSR, use a dynamic import inside a client-only component:
// LazyMapComponent.jsx — only rendered on the client
import { useEffect, useState } from 'react'
export function LazyMap({ coords }) {
const [MapLib, setMapLib] = useState(null)
useEffect(() => {
import('leaflet').then(m => setMapLib(() => m.default))
}, [])
if (!MapLib) return <div>Loading map…</div>
return <MapLib.Map center={coords} />
}
Streaming SSR
If you’re on React 18 or Vue 3, you can stream HTML to the client instead of waiting for the full render. This is a significant UX win — time-to-first-byte drops and the browser can start parsing before the server finishes rendering:
// entry-server.js — streaming variant
import { renderToPipeableStream } from 'react-dom/server'
import App from './App'
export function render(url, res) {
const { pipe } = renderToPipeableStream(
<StaticRouter location={url}><App /></StaticRouter>,
{
onShellReady() {
res.setHeader('Content-Type', 'text/html')
pipe(res)
},
onError(err) {
console.error(err)
},
}
)
}
The tradeoff: streaming makes error handling and status code setting more complex, because you’ve already started sending bytes before you know if a downstream component threw. Suspend-based data fetching (React Suspense + a loader) is the production-grade solution; plain streaming without it mostly helps for static shell delivery.
Putting It Together: A Production vite.config.js
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import viteCompression from 'vite-plugin-compression'
export default defineConfig(({ command, isSsrBuild }) => ({
plugins: [
react(),
// Only compress on client production builds
...(command === 'build' && !isSsrBuild
? [
viteCompression({ algorithm: 'brotliCompress', ext: '.br', threshold: 10240 }),
viteCompression({ algorithm: 'gzip', ext: '.gz', threshold: 10240 }),
]
: []
),
],
build: {
// Warn if a single chunk exceeds 500 KB uncompressed
chunkSizeWarningLimit: 500,
// Inline small assets as base64
assetsInlineLimit: 8192,
// Keep CSS with the chunks that need them
cssCodeSplit: true,
rollupOptions: {
output: {
chunkFileNames: 'assets/js/[name]-[hash].js',
entryFileNames: 'assets/js/[name]-[hash].js',
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
manualChunks: isSsrBuild
? undefined // No manual splitting for SSR bundle
: (id) => {
if (id.includes('node_modules/react') || id.includes('node_modules/react-dom')) {
return 'vendor-react'
}
if (id.includes('node_modules/react-router')) {
return 'vendor-router'
}
if (id.includes('node_modules')) {
return 'vendor'
}
},
},
},
},
ssr: {
// Bundle ESM-only packages that can't be require()'d
noExternal: [],
// Never bundle native addons
external: ['sharp'],
},
}))
Measuring What You Actually Changed
None of this matters if you’re not measuring. Two tools worth knowing:
rollup-plugin-visualizer — generates an interactive treemap of your bundle. Run it once after any significant dependency change:
npm install -D rollup-plugin-visualizer
import { visualizer } from 'rollup-plugin-visualizer'
plugins: [
visualizer({ open: true, gzipSize: true, filename: 'dist/stats.html' }),
]
vite-bundle-analyzer — similar, built specifically for Vite, slightly nicer UI for multi-chunk setups.
After optimizing, your key metrics to track: initial JS payload (gzip), number of requests on first load, and cache hit rate after a deploy that only touches app code (vendor chunks should not bust).
The Part Nobody Talks About: Build Reproducibility
Rollup’s chunking can be non-deterministic across environments when you use the function form of manualChunks. A module’s ID includes its absolute path, which differs between your Mac and a CI runner. Use id.includes('node_modules/package-name') rather than full path matching, and you’ll stay consistent. If you need hard reproducibility, the object form of manualChunks is fully deterministic.
Cache your node_modules in CI and pin your Vite and Rollup versions in package.json. A Vite minor bump has broken chunk splitting behavior before — it’s not common, but it happens.
That’s the full picture. Chunk splitting gets your cache strategy right. Asset optimization cuts transfer size. SSR patterns let you render on the server without surprises. Each area is independent — you can adopt them incrementally without rewriting your setup from scratch.