Vite 20 ⚡ Build Targets — Browser compatibility & @vitejs/plugin-legacy
Vite’s build target configuration is a declaration of intent: these are the browsers the output must run in. The declaration has consequences. A target that is too modern excludes users on older devices. A target that is too old transforms syntax that did not need transforming, inflating the bundle and slowing the build. Vite 8 changed the default target to a specific, dated snapshot of “Baseline Widely Available” browsers, which means the default is no longer a moving window of “recent versions” but a fixed list that advances with each major release . The @vitejs/plugin-legacy package exists for the case where the target must extend beyond that baseline, but the plugin’s constraints changed in Vite 8, and the change is not obvious from the plugin’s documentation alone.
The distinction between build.target and the legacy plugin is the distinction between transpilation and polyfilling. build.target controls which syntax features Vite’s transformer (Oxc in Vite 8) is allowed to emit. It does not add runtime polyfills for APIs that the target browser lacks. The legacy plugin generates a separate bundle for browsers that do not support native ESM and injects polyfills for both the legacy and modern bundles. The two mechanisms address different compatibility gaps, and conflating them produces either a bundle that fails on old browsers or a legacy build that was never needed.
Key point: build.target sets the syntax level for the modern bundle. @vitejs/plugin-legacy creates an additional legacy bundle for browsers without native ESM support. In Vite 8, the legacy plugin’s ES5 lowering is not supported by the Rolldown-based pipeline, which changes what the plugin can actually do for the oldest browsers.
Why the target configuration matters
The default target is dated, not floating. Vite 8’s default build.target is 'baseline-widely-available', which resolves to a fixed list based on the Baseline Widely Available status as of January 1, 2026: Chrome 111, Edge 111, Firefox 114, Safari 16.4, and iOS 16.4 . This is a deliberate change from earlier versions where the default tracked a more recent browser set. The rationale is stability: a project that builds today and a project that builds six months from now with the same Vite version produce the same target list. The trade-off is that the default does not automatically include browsers that became “widely available” after the snapshot date. A project that needs to support Chrome 120 must set the target explicitly.
The syntax-versus-API gap. Setting build.target: 'chrome90' tells Vite to transform syntax that Chrome 90 does not understand. It does not tell Vite to include a polyfill for Array.prototype.at() or structuredClone(). Those are runtime APIs, and syntax transpilation does not add them. The legacy plugin’s polyfill generation is what addresses runtime APIs, but only for the legacy bundle. The modern bundle receives no polyfills unless modernPolyfills is explicitly enabled . A project that targets Chrome 90 but uses structuredClone() without a polyfill will fail on Chrome 90 regardless of the build target.
The legacy plugin’s Vite 8 constraint. The @vitejs/plugin-legacy package’s README describes its behavior in terms that imply ES5 support for old browsers. But Vite 8’s migration notes state plainly: “@vitejs/plugin-legacy ES5 lowering — Not supported on Rolldown” . The plugin still generates legacy chunks transformed with @babel/preset-env, and those chunks are emitted as SystemJS modules with polyfills . What is not supported is the specific transformation of legacy chunks down to ES5 syntax through the Rolldown pipeline. A project that needs to support browsers without ES2015 support cannot rely on the legacy plugin alone in Vite 8; it must either target those browsers through a different mechanism or accept that the oldest browsers are outside the supported range.
The trade-off. A broader target range means more transformation and more polyfills, which means a larger bundle and a slower build. A narrower target means a smaller, faster output that excludes some users. The decision is a product decision, not a technical one, and it should be made with actual analytics data about the browsers the application’s users actually use. The default target is a reasonable starting point, but it is not a substitute for knowing the audience.
a. Configuring build.target and build.cssTarget
The build.target option accepts a string, an array of strings, or an ES version. The build.cssTarget option is separate because CSS features and JavaScript features do not always advance together.
// vite.config.ts — explicit target configuration
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
// Target specific browser versions.
// The transformer emits syntax that these browsers support.
target: ['chrome100', 'firefox100', 'safari15.4'],
// CSS target can differ from the JS target.
// Use this when a browser supports modern JS but not modern CSS.
// Example: Android WeChat WebView supports most JS features
// but does not support the #RGBA hex color notation.
cssTarget: ['chrome61'],
},
});
The cssTarget option exists because the JavaScript transformer and the CSS minifier have different compatibility matrices. A browser might support all the JavaScript syntax the target allows but lack support for a CSS color notation or a layout feature. Setting cssTarget independently prevents the CSS minifier from producing output that the JS target’s browsers cannot parse .
// vite.config.ts — ES version target
export default defineConfig({
build: {
// Target an ES version instead of specific browsers.
// 'es2015' means the output will use ES2015 syntax.
// Browsers that support ES2015 can run it.
target: 'es2015',
},
});
The ES version target is simpler but less precise. A browser might support ES2015 syntax but not a specific API, or it might support ES2020 syntax while the project only needs ES2015. The browser-version target is the more common choice because it maps directly to the compatibility data that tools like Can I Use provide.
b. Using @vitejs/plugin-legacy
The legacy plugin generates a second bundle for browsers that do not support native ESM. It injects <script nomodule> tags so that only the browsers that need the legacy bundle load it. The modern browsers load the modern bundle and never download the legacy code.
// vite.config.ts — legacy plugin configuration
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import legacy from '@vitejs/plugin-legacy';
export default defineConfig({
plugins: [
react(),
legacy({
// The browsers to target with the legacy bundle.
// This is passed to @babel/preset-env.
// The query is Browserslist-compatible.
targets: ['defaults', 'not IE 11'],
}),
],
});
The plugin’s default targets value is 'last 2 versions and not dead, > 0.3%, Firefox ESR' . The not IE 11 addition is common because IE 11 does not support native ESM and requires the legacy bundle, but supporting IE 11 also means accepting the largest possible legacy bundle and the most polyfills. A project that does not need IE 11 support excludes it explicitly.
// vite.config.ts — legacy plugin with modern polyfills
export default defineConfig({
plugins: [
legacy({
targets: ['defaults', 'not IE 11'],
// Enabling modernPolyfills generates a separate polyfill chunk
// for the modern bundle. This is not recommended without
// setting modernTargets because core-js is aggressive
// and can inject 15KB of unnecessary polyfills.
modernPolyfills: false,
}),
],
});
The modernPolyfills option defaults to false, and the documentation explicitly warns against enabling it without setting modernTargets because the auto-detection is overly broad . The modern bundle is meant to be lean; adding polyfills to it defeats the purpose of the modern/legacy split.
c. The Vite 8 legacy constraint and the polyfill fallback
The Vite 8 migration notes state that ES5 lowering through the legacy plugin is not supported on the Rolldown pipeline . The practical implication is that the legacy plugin still produces legacy chunks and polyfills, but the transformation does not go all the way down to ES5. A project that needs to support browsers without ES2015 support must handle that requirement outside the default plugin configuration.
// vite.config.ts — explicit polyfill handling
import { defineConfig } from 'vite';
import legacy from '@vitejs/plugin-legacy';
export default defineConfig({
plugins: [
legacy({
targets: ['defaults', 'not IE 11'],
// additionalLegacyPolyfills adds imports to the legacy polyfill chunk.
// Use this for DOM APIs that the usage-based detection does not cover.
additionalLegacyPolyfills: ['regenerator-runtime/runtime'],
// renderLegacyChunks can be disabled if only modern polyfills are needed.
// This is not the common case.
renderLegacyChunks: true,
}),
],
});
The additionalLegacyPolyfills option is the escape hatch for APIs that @babel/preset-env‘s usage detection does not catch. The usage detection covers ES language features, but DOM APIs and newer runtime features may require explicit polyfill imports . A project that uses a DOM API not present in the target browsers must add it here or load the polyfill separately.
The Vite 8 constraint on ES5 means that the absolute oldest browsers—those without ES2015 support—are effectively outside the default plugin’s reach. The plugin’s documentation still describes the legacy chunk generation in terms of @babel/preset-env and SystemJS, and those chunks are produced. What changed is the transformation pipeline’s ability to lower the output to ES5 through Rolldown. For projects where ES5 support is a hard requirement, the migration to Vite 8 introduces a gap that must be addressed with a custom Babel configuration or a different build strategy.
Complete Example Session
// ============================================
// PART 1: THE DEFAULT TARGET
// ============================================
// vite.config.ts — no target configuration
export default defineConfig({
plugins: [react()],
});
// The default target is 'baseline-widely-available',
// resolving to ['chrome111', 'edge111', 'firefox114', 'safari16.4', 'ios16.4']
// as of the 2026-01-01 snapshot .
//
// The transformer emits syntax compatible with these browsers.
// No polyfills are added for runtime APIs.
// ============================================
// PART 2: THE EXPLICIT TARGET
// ============================================
export default defineConfig({
build: {
target: ['chrome100', 'firefox100', 'safari15.4'],
},
});
// The output uses syntax that Chrome 100, Firefox 100,
// and Safari 15.4 support.
// A browser newer than these targets can still run the output.
// ============================================
// PART 3: THE CSS TARGET OVERRIDE
// ============================================
export default defineConfig({
build: {
target: ['chrome100'],
// CSS target is separate because CSS features
// and JS features do not advance together.
cssTarget: ['chrome61'],
},
});
// A browser that supports Chrome 100 JS syntax
// but not the #RGBA CSS color notation
// is the case this option exists for .
// ============================================
// PART 4: THE LEGACY PLUGIN
// ============================================
import legacy from '@vitejs/plugin-legacy';
export default defineConfig({
plugins: [
react(),
legacy({
targets: ['defaults', 'not IE 11'],
}),
],
});
// The plugin generates:
// 1. A legacy chunk for every modern chunk (SystemJS format).
// 2. A polyfill chunk with SystemJS runtime and necessary polyfills.
// 3. <script nomodule> tags in the HTML.
// 4. import.meta.env.LEGACY set to true in the legacy build .
// ============================================
// PART 5: THE POLYFILL OPTIONS
// ============================================
legacy({
targets: ['defaults', 'not IE 11'],
// polyfills: true is the default.
// The polyfill chunk is generated based on target browsers
// and actual usage in the bundle.
polyfills: true,
// additionalLegacyPolyfills adds custom imports
// for DOM APIs not covered by usage detection.
additionalLegacyPolyfills: ['regenerator-runtime/runtime'],
});
// ============================================
// PART 6: THE VITE 8 ES5 CONSTRAINT
// ============================================
// Vite 8 migration notes:
// "@vitejs/plugin-legacy ES5 lowering — Not supported on Rolldown"
//
// The plugin still generates legacy chunks and polyfills,
// but the transformation does not go down to ES5.
//
// A project that needs ES5 support must:
// - Configure a custom Babel step outside the plugin, or
// - Accept that the oldest browsers are outside the supported range.
// ============================================
// PART 7: THE MODERN POLYFILL WARNING
// ============================================
legacy({
targets: ['defaults', 'not IE 11'],
// modernPolyfills: true is NOT recommended without modernTargets.
// The auto-detection is aggressive and can inject 15KB of polyfills
// even when targeting native ESM support .
modernPolyfills: false,
});
// ============================================
// PART 8: THE TERSER DEPENDENCY
// ============================================
// The legacy plugin uses Terser for minification.
// Install it as a dev dependency.
// npm add -D terser
// In Vite 8, if build.minify is 'oxc' (the default),
// Terser is not used for the modern bundle.
// The legacy plugin's minification path may still require it.
// ============================================
// PART 9: VERIFICATION
// ============================================
// 1. Build: npm run build
// 2. Inspect dist/: verify the legacy chunks and polyfill chunks exist.
// 3. Open the built HTML: verify <script nomodule> tags are present.
// 4. Test in a modern browser: the modern bundle loads.
// 5. Test in an older browser (or simulate with a user-agent switcher):
// the legacy bundle and polyfills load.
// 6. Check the legacy chunk for ES5 syntax if ES5 support is required.
// In Vite 8, this check may fail due to the Rolldown constraint.
// ============================================
// PART 10: THE DECISION CHECKLIST
// ============================================
// [ ] Do I know which browsers my users actually use?
// [ ] Is the default 'baseline-widely-available' target sufficient?
// [ ] Do I need to support browsers older than the baseline?
// [ ] If yes, does the legacy plugin cover the gap?
// [ ] Do I need ES5 support? If yes, the Vite 8 plugin does not provide it.
// [ ] Does my CSS target match my JS target, or do I need cssTarget?
// [ ] Have I added polyfills for runtime APIs not covered by syntax transpilation?
The ten parts covered the default target, the explicit target, the CSS target, the legacy plugin’s behavior, the polyfill options, the Vite 8 ES5 constraint, the modern polyfill warning, and the verification steps. The through-line is that target configuration is a layered decision: syntax, APIs, and legacy support are three separate concerns.
Quick Reference
Target configuration options
| Option | Type | Default | Purpose |
|---|---|---|---|
build.target | string | string[] | 'baseline-widely-available' | Syntax level for JS |
build.cssTarget | string | string[] | Same as build.target | Syntax level for CSS |
build.minify | boolean | string | 'oxc' (Vite 8) | Minifier selection |
Legacy plugin options
| Option | Default | Purpose |
|---|---|---|
targets | 'last 2 versions and not dead, > 0.3%, Firefox ESR' | Browsers for legacy bundle |
polyfills | true | Generate polyfill chunk |
modernPolyfills | false | Polyfills for modern bundle |
additionalLegacyPolyfills | [] | Custom polyfill imports |
renderLegacyChunks | true | Generate legacy chunks |
Vite 8 target resolution
| Default value | Resolves to |
|---|---|
'baseline-widely-available' | ['chrome111', 'edge111', 'firefox114', 'safari16.4', 'ios16.4'] |
Source:
Best Practices
✅ Do This:
// Set an explicit target based on analytics
build: { target: ['chrome100', 'safari15.4'] } // ✅
// Use cssTarget when CSS and JS compatibility differ
build: { cssTarget: ['chrome61'] } // ✅
// Exclude IE 11 if not needed
legacy({ targets: ['defaults', 'not IE 11'] }) // ✅
// Add explicit polyfills for DOM APIs
additionalLegacyPolyfills: ['regenerator-runtime/runtime'] // ✅
❌ Don’t Do This:
// Assume build.target adds polyfills
build: { target: 'es2015' } // No runtime polyfills added // ❌
// Enable modernPolyfills without modernTargets
legacy({ modernPolyfills: true }) // Aggressive polyfill injection // ❌
// Rely on the legacy plugin for ES5 support in Vite 8
// ES5 lowering is not supported on Rolldown // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Older browsers fail on runtime APIs | build.target only controls syntax, not APIs | Add polyfills via legacy plugin or manual imports |
| Legacy bundle is unexpectedly large | modernPolyfills enabled with auto-detection | Set modernPolyfills: false or set modernTargets |
| ES5 support is missing in Vite 8 | Rolldown pipeline does not support ES5 lowering | Accept the constraint or use a custom Babel step |
| CSS breaks on a specific browser | cssTarget not set separately | Set cssTarget to the browser’s version |
Real-World Examples
1. Default target
build: { target: 'baseline-widely-available' }
2. Explicit browser versions
build: { target: ['chrome100', 'firefox100', 'safari15.4'] }
3. ES version target
build: { target: 'es2015' }
4. CSS target override
build: { cssTarget: ['chrome61'] }
5. Legacy plugin basic
legacy({ targets: ['defaults', 'not IE 11'] })
6. Additional polyfills
legacy({ additionalLegacyPolyfills: ['regenerator-runtime/runtime'] })
7. Disable modern polyfills
legacy({ modernPolyfills: false })
8. Check legacy chunks
ls dist/assets/*legacy*
9. Check nomodule tags
grep nomodule dist/index.html
10. Install Terser
npm add -D terser
Visual
The Target Decision Tree
┌──────────────────────────────────────────────────────────────┐
│ FROM USER DATA TO CONFIGURATION │
│ │
│ What browsers do users actually use? │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Are they all │──── Yes ───▶ Default target is fine │
│ │ in the baseline?│ │
│ └────────┬────────┘ │
│ │ No │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Do they support │──── Yes ───▶ Set build.target to │
│ │ native ESM? │ the oldest version │
│ └────────┬────────┘ │
│ │ No │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Use legacy │ │
│ │ plugin │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Do they need │──── Yes ───▶ Vite 8 does not support │
│ │ ES5? │ ES5 lowering. Accept or │
│ └────────┬────────┘ use custom Babel. │
│ │ No │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Legacy plugin │ │
│ │ covers it │ │
│ └─────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Syntax vs API Compatibility
┌──────────────────────────────────────────────────────────────┐
│ TWO DIFFERENT GAPS │
│ │
│ Syntax gap API gap │
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ Optional chaining │ │ structuredClone() │ │
│ │ Nullish coalescing │ │ Array.at() │ │
│ │ Class fields │ │ Intl.RelativeTime │ │
│ └─────────┬──────────┘ └─────────┬──────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ build.target │ │ Polyfills │ │
│ │ (transformer) │ │ (legacy plugin or │ │
│ │ │ │ manual imports) │ │
│ └────────────────────┘ └────────────────────┘ │
│ │
│ Setting the target does not add the polyfill. │
│ Adding the polyfill does not change the target. │
└──────────────────────────────────────────────────────────────┘
The Legacy Bundle Flow
┌──────────────────────────────────────────────────────────────┐
│ MODERN AND LEGACY BUNDLES │
│ │
│ Build │
│ │ │
│ ├──▶ Modern bundle (ESM) │
│ │ <script type="module"> │
│ │ Modern browsers load this. │
│ │ No polyfills by default. │
│ │ │
│ └──▶ Legacy bundle (SystemJS) │
│ <script nomodule> │
│ Older browsers load this. │
│ Polyfill chunk included. │
│ │
│ A browser that supports ESM never downloads the legacy code.│
│ A browser that does not support ESM skips the modern bundle.│
│ │
│ The two bundles coexist in the same dist/ directory. │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
Default build.target | 'baseline-widely-available' |
| Resolves to (Vite 8) | Chrome 111, Edge 111, Firefox 114, Safari 16.4, iOS 16.4 |
build.cssTarget | Separate CSS compatibility target |
| Legacy plugin default targets | 'last 2 versions and not dead, > 0.3%, Firefox ESR' |
| Legacy plugin polyfills | Generated based on targets and usage |
| Vite 8 ES5 constraint | Not supported on Rolldown |
| Modern polyfills | Not recommended without modernTargets |
Key takeaways:
build.targetcontrols syntax, not APIs. The transformer emits syntax the target browsers support, but it does not add polyfills for runtime APIs. A target that excludes a browser’s syntax support does not automatically exclude its missing APIs .- The Vite 8 default target is a dated snapshot. It resolves to a fixed list of browser versions based on the 2026-01-01 Baseline Widely Available status. Projects that need to support newer browsers must set the target explicitly .
- The legacy plugin generates a second bundle for non-ESM browsers. It produces legacy chunks, polyfill chunks, and
<script nomodule>tags. The modern bundle remains lean unlessmodernPolyfillsis enabled, which is not recommended withoutmodernTargets. - Vite 8 does not support ES5 lowering through the legacy plugin. The Rolldown pipeline does not transform legacy chunks down to ES5. Projects that require ES5 support must handle it outside the plugin or accept that the oldest browsers are excluded .
cssTargetexists because CSS and JS compatibility differ. A browser might support the JavaScript syntax the target allows but lack support for a CSS notation or feature. SettingcssTargetindependently prevents the CSS minifier from producing incompatible output .
Remember: The build target is a declaration of which browsers the output must support, and the declaration has two dimensions: syntax and APIs. Vite’s transformer handles the syntax dimension through build.target. The legacy plugin handles the API dimension through polyfills and the legacy bundle. Neither dimension is complete without the other. A project that sets a modern target and does not add polyfills is a project that works in the developer’s browser and fails on the user’s older device. A project that adds every polyfill and targets every browser is a project that ships megabytes of compatibility code to users who do not need it. The target should be set from data, not from default, and the legacy plugin should be enabled when the data says it is necessary, not because the plugin exists.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!