TypeScript

SystemJS Is Dead. Native ESM Finally Won.

I ran into a SystemJS config file last month buried inside a legacy Angular project. The kind with System.config({ packages: { app: { ... } } }), fifteen ...

30 Apr 2026

SystemJS Is Dead. Native ESM Finally Won.

I ran into a SystemJS config file last month buried inside a legacy Angular project. The kind with System.config({ packages: { app: { ... } } }), fifteen nested conditions, and a comment at the top that said "do not touch". The engineer who wrote it had left the company years ago. Nobody touched it. Nobody understood it. It worked, and that was enough.

That project is still in production. But if you are starting something today, or migrating something that can be migrated, SystemJS should not be part of the picture. Native ES modules have been stable across all modern browsers for years. The ecosystem has caught up. The polyfill era is over.

Why SystemJS Existed

Back in 2014, browsers had no native module system. JavaScript files were concatenated, globals leaked everywhere, and AMD (RequireJS) and CommonJS (Node.js) were fighting over which approach would win. SystemJS stepped in as a universal module loader that could handle AMD, CommonJS, and ES modules all at once, in the browser, at runtime.

For a while it was genuinely necessary. Angular 2 shipped with it. Single-SPA used it for micro-frontends. It solved a real problem: you wanted to write import syntax before browsers understood import syntax.

The problem is that browsers now understand import syntax. They have for a while.

What Native ESM Gives You

<script type="module"> landed in all major browsers around 2017-2018. By 2020 it was baseline everywhere that mattered. When you write this:

Html
<script type="module" src="./app.js"></script>

The browser fetches app.js, parses its imports, fetches those, and resolves the whole module graph. No loader needed. No runtime overhead. The browser's module resolution is spec-compliant, consistent, and fast.

Inside a module script, import and export work as you'd expect:

Js
import { formatDate } from './utils/date.js'
export function renderHeader(date) {
  return `<h1>${formatDate(date)}</h1>`
}

The .js extension matters here. Browser ESM requires explicit extensions. That surprises people coming from a bundler world where webpack resolves ./utils/date by trying every extension it knows about. The browser does not do that. It fetches literally what you give it.

Dynamic imports also work natively:

Js
const { renderChart } = await import('./charts/bar.js')

This is lazy loading without a bundler. No webpack magic chunks. Just a native promise that resolves when the module is fetched and evaluated.

Import Maps Changed Everything

The one legitimate knock against raw native ESM was bare specifiers. You could not write:

Js
import React from 'react'

Because the browser has no idea where react lives. It is not a file path. It is not a URL. Bundlers resolve it by walking node_modules, but the browser cannot do that.

Import maps fix this. They ship a JSON-controlled mapping from bare specifier to URL, loaded before any module script runs:

Html
<script type="importmap">
{
  "imports": {
    "react": "https://esm.sh/react@18.3.1",
    "react-dom/client": "https://esm.sh/react-dom@18.3.1/client"
  }
}
</script>
<script type="module" src="./app.js"></script>

Now import React from 'react' resolves correctly, no bundler involved. Import maps have been in Chrome since version 89 (2021), Firefox since 108 (2022), and Safari since 16.4 (2023). As of 2024 they are in the Baseline Widely Available set.

This is the feature that made SystemJS's final use case obsolete. Before import maps, you needed something to translate bare specifiers at runtime. SystemJS was the thing. Now you don't.

Where Bundlers Still Belong

None of this means you should ship raw ES modules to production without a bundler. For anything at scale, bundling still matters. HTTP/2 helps with many small requests, but not infinitely. A React app with hundreds of modules and node_modules dependencies is not something you want resolved by the browser one request at a time on a mobile network.

Vite, esbuild, and Rollup all produce ESM output. That is important. They are not replacing ESM, they are pre-processing it. The distinction matters because it means your development tooling can use native ESM (Vite's dev server does this), while production ships a bundled ESM artifact. Both ends speak the same module format.

SystemJS spoke its own format. Your code was transpiled into System.register() calls, which only SystemJS understood. Swap out the loader, rewrite everything. With native ESM, the format and the runtime are the same thing. There is no lock-in.

The Micro-Frontend Case

One place SystemJS held on longest was micro-frontends, specifically the single-spa architecture. The promise was that you could load independently deployed applications into a shell at runtime without coordination between teams. SystemJS made that work by loading remote System.register bundles on demand.

Import maps offer an alternative here. Deploy each micro-app as a native ESM bundle and update a central import map when you ship a new version. The shell script tag stays the same. The import map entry changes. No SystemJS intermediary. Some teams have moved this direction using tools like import-map-deployer alongside Vite-built micro-apps.

It is not the only approach and it has its own coordination problems. But the point is the problem is solvable without a runtime module loader.

What to Actually Do

If you have an existing project that uses SystemJS and it's working, there is no urgent reason to rip it out. Technical debt that is not causing pain is not the top priority. But if you are hitting it for maintenance, upgrading it, or if it is the thing causing the pain, the migration path is clear: switch to a Vite or webpack/Rollup build that outputs standard ESM, and remove the SystemJS dependency.

For new projects, the answer is simpler. Use a modern bundler that outputs ESM. If you need runtime module loading (unlikely for most apps), use dynamic import() and import maps. Do not add SystemJS. It was a solution for a problem the platform has since solved.

The browser is not waiting for a loader to tell it how modules work anymore. That shift happened quietly while we were all heads-down in our bundler configs, but it happened. Writing <script type="module"> and getting spec-compliant, interoperable module semantics out of the browser is not a trick or a workaround. It is just how it works now.


If you are thinking through a migration like this or evaluating your frontend architecture, the Monday BY Gazar newsletter covers these kinds of decisions weekly. And if you want to dig into this with me directly, book a free intro call.

Keep reading