Technology Aug 31, 2026 · 8 min read

What Browserslist Actually Does in Next.js

We set up three near-identical Next.js 16 projects to answer a simple question: if you set a browserslist target in package.json, does the build change to match it? We tested three targets (ie 11, chrome 116, about 3 years old, and chrome 139, about 1 year old) and compared the compiled output byte...

DE
DEV Community
by Alessandro Grosselle
What Browserslist Actually Does in Next.js

We set up three near-identical Next.js 16 projects to answer a simple question: if you set a browserslist target in package.json, does the build change to match it?
We tested three targets (ie 11, chrome 116, about 3 years old, and chrome 139, about 1 year old) and compared the compiled output byte by byte.

The code for all three projects is on GitHub: ale-grosselle/nextjs-browserlist-test.

The setup

All three projects are otherwise identical; here's the source we're compiling:

export type Browser = { name: string; usage?: number };

export function summarize(browsers: Browser[]) {
  // logical assignment (ES2021)
  for (const b of browsers) {
    b.usage ??= 0;
  }

  // Array.prototype.at (ES2022)
  const last = browsers.at(-1);

  // Object.hasOwn (ES2022)
  const hasUsage = browsers.some((b) => Object.hasOwn(b, "usage"));

  // optional chaining + nullish coalescing (ES2020)
  const lastName = last?.name ?? "unknown";

  // structuredClone (widely available, ES2022-ish web API)
  const clone = structuredClone(browsers);

  // Array.prototype.group / Object.groupBy (ES2024)
  const byLeadingLetter =
    typeof Object.groupBy === "function"
      ? Object.groupBy(browsers, (b) => b.name[0])
      : null;

  // Error.isError (ES2026)
  try {
    throw "Oops; this is not an Error object";
  } catch (e) {
    if (!Error.isError(e)) {
      console.log("ERROR!!")
    }
  }

  return { lastName, hasUsage, clone, byLeadingLetter };
}

And the three browserslist fields that are the only difference between the projects:

// ie11/package.json
"browserslist": ["ie 11"]

// chrome-3y-ago/package.json
"browserslist": ["chrome 116"]

// chrome-1y-ago/package.json
"browserslist": ["chrome 139"]

Each app also ships eslint-plugin-compat and a postcss.config.js with postcss-preset-env, so we could check whether the tooling that's supposed to consume browserslist actually does.

We ran next build on each and diffed the emitted chunks.

Part 1: JavaScript

What we found

Running the same summarize() function through all three builds, we expected three different outputs, scaled to how old each target is. We got two identical outputs and one different one.

IE11 build, genuinely transpiled to ES5:

function(){
  var t=!0,n=!1,e=void 0;
  try{
    for(var u,o,a=r[Symbol.iterator]();!(t=(o=a.next()).done);t=!0){
      var l=o.value;
      null!=l.usage||(l.usage=0)
    }
  }catch(r){n=!0,e=r}
  finally{try{t||null==a.return||a.return()}finally{if(n)throw e}}
  var i=r.at(-1),
      c=r.some(function(r){return Object.hasOwn(r,"usage")});
  try{throw"Oops; this is not an Error object"}catch(r){Error.isError(r)||console.log("ERROR!!")}
  return{
    lastName:null!=(u=null==i?void 0:i.name)?u:"unknown",
    hasUsage:c,
    clone:structuredClone(r),
    byLeadingLetter:"function"==typeof Object.groupBy?Object.groupBy(r,function(r){return r.name[0]}):null
  }
}

No arrow functions, no let/const, for...of rewritten as a manual Symbol.iterator loop, optional chaining and ??= rewritten as ternaries and ||. SWC is doing real syntax-level downleveling here.

Chrome 116 build and Chrome 139 build, byte-for-byte the same syntax:

onClick:()=>u(function(e){
  for(let n of e) n.usage??=0;
  let n=e.at(-1),
      t=e.some(e=>Object.hasOwn(e,"usage")),
      r=n?.name??"unknown";
  try{throw"Oops; this is not an Error object"}catch(e){Error.isError(e)||console.log("ERROR!!")}
  return{
    lastName:r,
    hasUsage:t,
    clone:structuredClone(e),
    byLeadingLetter:"function"==typeof Object.groupBy?Object.groupBy(e,e=>e.name[0]):null
  }
}(r))

Arrow functions, let, ??=, ?., for...of, none of it touched, in either build. Setting chrome 116 vs chrome 139 had zero effect on the emitted JS syntax.

Why: SWC's target resolution is binary, not a gradient

Next.js's SWC compiler doesn't map browserslist to a continuum of ECMAScript versions the way a Babel plus preset-env pipeline would. It effectively collapses every target into one of two buckets:

  • legacy: browsers that don't support native ES modules (<script type="module">). IE11 falls here.
  • modern: everything from roughly Chrome 61, Safari 11, or Firefox 60 onward (2017 and later), meaning anything with native module support.

Chrome 116 and Chrome 139 both live deep inside the modern bucket, so there's no third or fourth tier that would distinguish them.
You'd need to cross the module-support line, down into legacy Edge or an old Android browser, to see any difference at all.
In practice, browserslist only matters to Next.js/SWC's JS output if your oldest target predates ES module support.

The bigger problem: even the IE11 build doesn't polyfill anything

Look again at the IE11 snippet: r.at(-1), Object.hasOwn(r,"usage"), structuredClone(r), and now Error.isError(r) are called directly, unpolyfilled, and none of these exist in real IE11.
Error.isError is worth calling out on its own: it's an ES2026 method, not even shipping in real browsers yet at the time of writing, and it still gets called as a bare, unchanged reference in the IE11 build.

SWC only lowers syntax; it never runs anything like core-js to inject polyfills for missing APIs, regardless of browserslist. If you actually need IE11 (or any legacy target) to run without runtime crashes, you have to bring your own polyfill entrypoint. Next.js's browserslist integration won't do it for you.

Part 2: CSS

CSS tells a different, more encouraging story: the output differs between Chrome 116 and Chrome 139, not just between IE11 and everything else.

Feature IE11 chrome 116 chrome 139
font-family: system-ui expanded to full fallback stack unchanged unchanged
color-scheme: light dark--lightningcss-light/dark fallback vars + prefers-color-scheme block present present absent
CSS nesting (&) flattened flattened flattened
color-mix() unchanged unchanged unchanged
:has() unchanged unchanged unchanged
@container unchanged unchanged unchanged

The font-family case, IE11 only:

/* source */
body { font-family: system-ui, sans-serif; }

/* IE11 output */
body{font-family:system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Noto Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif}

The color-scheme case, which is the one that actually splits the two Chrome builds:

/* source */
:root {
  --gap: 1rem;
  color-scheme: light dark;
}

/* chrome 116 AND ie11 both get this */
:root{
  --gap:1rem;
  --lightningcss-light:initial;
  --lightningcss-dark: ;
  color-scheme:light dark
}
@media (prefers-color-scheme:dark){
  :root{--lightningcss-light: ;--lightningcss-dark:initial}
}

/* chrome 139 gets the plain version */
:root{--gap:1rem;color-scheme:light dark}

Why: Lightning CSS resolves browserslist per feature, not per bucket

Turbopack's CSS pipeline is built on Lightning CSS, which ships per-feature browser support data, closer to what you'd get from caniuse or Baseline, rather than SWC's binary modules-or-not split.
The light-dark() color-scheme resolution behavior landed in Chrome around version 123, squarely between our two targets (116 and 139), so Lightning CSS emits the fallback custom-properties trick for 116 but not for 139.
That's a real, meaningful distinction driven by the exact browserslist value, which is exactly what we couldn't find on the JS side.

But CSS has its own gaps

Features with no static fallback path ship unchanged to every target, including IE11: color-mix(), :has(), @container.
Unlike JS, where an unsupported call throws loudly, these just get shipped as invalid or ignored CSS in old browsers, a silent, easy-to-miss layout break rather than a crash.

Part 3: Linting, the missing half

On the JS side, eslint-plugin-compat works and is target-aware, up to a point. Running eslint with the ie 11 browserslist target correctly flags:

13:41  error  Object.hasOwn() is not supported in IE 11  compat/compat
19:17  error  structuredClone is not supported in IE 11  compat/compat

It produces zero errors for the same file under chrome 116 or chrome 139, which is the gap we opened this article with: browserslist granularity between two modern Chrome versions doesn't move the needle for linting either.

But Error.isError shows the real limit of that approach: it produces zero errors under all three targets, ie 11 included, not just the two Chrome versions.
Error.isError is a very recent addition (ES2026), and eslint-plugin-compat's own detection layer simply hasn't caught up with it yet, so the plugin never checks it against any target, on any browser, at all.
It's a maintenance-lag problem: the plugin is only as good as its most recently updated release, and brand-new syntax or APIs can slip through completely undetected until someone patches it upstream.

For CSS, we simply don't have any linting tool set up in these projects.
Nothing here lints color-mix(), :has(), or @container against the browserslist target; they pass silently even when targeting IE11, where they're guaranteed to fail or be ignored at runtime.
We haven't looked yet at what's out there for a browserslist-aware CSS linter, so this is an open question rather than a conclusion.

Our next step is to look for a better setup on both fronts: something that keeps up with newer JS syntax and APIs for the JS side, and an actual browserslist-aware linter for CSS, since right now that half of the pipeline has nothing watching it.

Takeaways

For JS, browserslist in a Next.js/SWC project only matters if your target crosses the "supports ES modules" line. Two modern Chrome versions, however many years apart, will get identical syntax. And even syntax downleveling for legacy targets ships zero API polyfills, so you're on your own for those.

For CSS, browserslist matters much more. Lightning CSS resolves it per feature, so two Chrome versions a couple of years apart can genuinely diverge in output. But features without a mechanical fallback (most new CSS) ship unchanged to every target.

Linting is where we left the most open questions, on both JS and CSS. See you in the next episode for an update on that front.

DE
Source

This article was originally published by DEV Community and written by Alessandro Grosselle.

Read original article on DEV Community
Back to Discover

Reading List