Implementing Import Maps for Module Management Without Bundler
Import Maps is a browser standard that allows managing the resolution of ES modules without Webpack, Rollup, or Vite. Instead of bundling into a single file, the browser itself resolves import 'react' to a specific URL according to the map. The specification has been stable since Chrome 89, Firefox 108, Safari 16.4.
Basic Principle
<!-- Without Import Maps — import will fail -->
<script type="module">
import React from 'react'; // ❌ bare specifier, browser doesn't know URL
</script>
<!-- With Import Maps — works -->
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/[email protected]",
"react-dom/client": "https://esm.sh/[email protected]/client",
"react/": "https://esm.sh/[email protected]/"
}
}
</script>
<script type="module">
import React from 'react'; // ✅
import { createRoot } from 'react-dom/client'; // ✅
</script>
<script type="importmap"> must be declared before any <script type="module">. One importmap per page.
Real Scenario: Multi-page Website Without Bundler
Typical situation: a marketing site with several interactive widgets. No need for a complex pipeline — just a shared vendor cache for React and a few components.
<!-- layouts/base.html -->
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/[email protected]?dev",
"react-dom": "https://esm.sh/[email protected]?dev",
"react-dom/client": "https://esm.sh/[email protected]/client?dev",
"@/": "/static/js/",
"htm/react": "https://esm.sh/[email protected]/react"
},
"scopes": {
"/admin/": {
"react": "https://esm.sh/[email protected]"
}
}
}
</script>
scopes allows you to set different library versions for different paths — useful for gradual migration.
Module Structure Without Bundler
/static/js/
├── components/
│ ├── Counter.js
│ ├── Tabs.js
│ └── SearchWidget.js
├── utils/
│ ├── api.js
│ └── format.js
└── app.js
// /static/js/components/Counter.js
import React, { useState } from 'react';
export function Counter({ initial = 0 }) {
const [count, setCount] = useState(initial);
return React.createElement('div', null,
React.createElement('button', { onClick: () => setCount(c => c - 1) }, '−'),
React.createElement('span', { style: { padding: '0 12px' } }, count),
React.createElement('button', { onClick: () => setCount(c => c + 1) }, '+'),
);
}
Without JSX — we use React.createElement or htm (tagged template literals as a JSX alternative without compiler):
// /static/js/components/SearchWidget.js
import { html } from 'htm/react';
import React, { useState, useCallback } from 'react';
import { debounce } from '@/utils/debounce.js';
export function SearchWidget({ endpoint }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const search = useCallback(
debounce(async (q) => {
if (q.length < 2) return;
const res = await fetch(`${endpoint}?q=${encodeURIComponent(q)}`);
setResults(await res.json());
}, 300),
[endpoint]
);
return html`
<div class="search">
<input
type="search"
value=${query}
onInput=${e => { setQuery(e.target.value); search(e.target.value); }}
placeholder="Search..."
/>
<ul>
${results.map(r => html`<li key=${r.id}>${r.title}</li>`)}
</ul>
</div>
`;
}
Connecting on Pages
<!-- /pages/catalog.html -->
<div id="search-root" data-endpoint="/api/search"></div>
<script type="module">
import { createRoot } from 'react-dom/client';
import { SearchWidget } from '@/components/SearchWidget.js';
const root = document.getElementById('search-root');
const endpoint = root.dataset.endpoint;
createRoot(root).render(
React.createElement(SearchWidget, { endpoint })
);
</script>
Version Management and Performance
CDNs like esm.sh and jsDelivr cache modules aggressively. For production, an explicit pinning strategy is needed:
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/stable/[email protected]/es2022/react.mjs",
"react-dom/client": "https://esm.sh/stable/[email protected]/es2022/client.mjs"
}
}
</script>
The /stable/ path on esm.sh guarantees the URL won't change — the browser will use the cache. Without pinning, esm.sh/react@18 can update and invalidate the cache.
For self-hosted: vendor modules go into /static/vendor/ and are versioned by file content hash:
<script type="importmap">
{
"imports": {
"react": "/static/vendor/react.18.3.1.a4f2c1.mjs",
"react-dom/client": "/static/vendor/react-dom-client.18.3.1.b7e3a2.mjs"
}
}
</script>
Generating Import Map from Lock File
For automation — a small Node.js script that generates importmap from package.json:
// scripts/generate-importmap.js
import { readFileSync, writeFileSync } from 'fs';
const pkg = JSON.parse(readFileSync('./package.json', 'utf8'));
const CDN_BASE = 'https://esm.sh';
const imports = {};
for (const [name, version] of Object.entries(pkg.importmap ?? {})) {
const v = version.replace(/[\^~]/, '');
imports[name] = `${CDN_BASE}/${name}@${v}`;
imports[`${name}/`] = `${CDN_BASE}/${name}@${v}/`;
}
const map = JSON.stringify({ imports }, null, 2);
// Insert into base template
let template = readFileSync('./templates/base.html', 'utf8');
template = template.replace(
/<script type="importmap">[\s\S]*?<\/script>/,
`<script type="importmap">\n${map}\n</script>`
);
writeFileSync('./templates/base.html', template);
console.log('Import map updated');
Limitations and When Not to Use
Import Maps are not suitable if you need: tree-shaking (each module is loaded whole), TypeScript without transpilation (the browser doesn't process .ts extensions), support for Safari before 16.4 and Firefox before 108.
For simple websites without a complex stack — a great alternative to Webpack with minimal tooling. For applications with thousands of components, typing, and hot reload — a bundler is still preferable.
Timeline
Setting up Import Maps for an existing project — one day. Developing an importmap generator from lock file and integration with deployment — another one to two days.







