Ship JavaScript without a bundler: import maps and JSON modules
How to structure a front end as native ES modules: import maps for bare imports, JSON modules, modulepreload against waterfalls, and cache-busting.
7 min read

In this article
You can now ship a small or medium front end as native ES modules with no bundler: an import map turns bare names like "preact" or "app/" into URLs, import attributes load JSON configuration as a module, and <link rel="modulepreload"> removes the request waterfall that used to make unbundled sites slow. All three work in current Chrome, Edge, Firefox and Safari. This guide explains how to structure such an app, where the approach fits and where a bundler is still the better choice.
Support versions come from MDN's browser compatibility data, version 8.1.5 (8 October 2026). The module code was run on Node.js 24, which understands the same import attribute syntax; the import map itself was checked against MDN's rules, since Node.js does not read import maps.
When "no build" is a sensible architecture
A bundler earns its place when you need JSX or TypeScript compilation, tree-shaking across hundreds of modules, or support for browsers older than about 2023. Native modules make sense when:
- the app is an admin screen, internal tool, documentation site, widget or progressive enhancement layer on server-rendered HTML;
- you write plain JavaScript (or use JSDoc types checked by
tsc --noEmitwithout emitting code); - the number of modules on a page is in the tens rather than the hundreds;
- you value a deploy that is just "copy files" and source maps that are the source.
| Concern | Bundled app | Native modules + import map |
|---|---|---|
| Build step | Required | None (optional hashing step) |
Bare imports such as "preact" | Resolved by the bundler | Resolved by the import map |
| Number of requests | Few, large files | One per module; HTTP/2 or HTTP/3 and modulepreload keep this fast |
| Cache invalidation | Hashed file names | Hashed names mapped in the import map |
| TypeScript, JSX | Compiled | Not available without a compile step |
Step 1: the import map
An import map is a <script type="importmap"> element containing JSON. It tells the browser how to resolve module specifiers in both static and dynamic imports:
<script type="importmap">
{
"imports": {
"preact": "/vendor/preact-10/preact.module.js",
"preact/hooks": "/vendor/preact-10/hooks.module.js",
"app/": "/js/"
}
}
</script>
<script type="module" src="/js/main.js"></script>Now any module can write import { h, render } from 'preact' or import { formatPrice } from 'app/lib/format.js'. The rules MDN documents are short but strict:
- The allowed top-level keys are
imports,scopesandintegrity. - Values must be absolute URLs or start with
/,./or../. - A key ending in
/is a prefix mapping, and its value must also end in/. - When several keys match, the most specific one wins, so
"preact/hooks"beats a"preact/"prefix. - The map must be inline. The
srcattribute is not allowed, so you cannot load an import map from a separate file. - It must be processed before any module that relies on it is loaded. Put it in the
<head>, before the firsttype="module"script.
Import maps are supported from Chrome and Edge 89, Firefox 108 and Safari 16.4. Multiple import maps on one page, which merge into one global map, are supported from Chrome 133 and Safari 18.4; in Firefox the feature is still behind a flag, so keep a single map if you need all browsers.

Scopes: two versions of one package
scopes remaps a specifier only for modules under a given path, which helps during a gradual upgrade:
{
"imports": { "chart-lib": "/vendor/chart-lib-5/index.js" },
"scopes": {
"/js/legacy-reports/": { "chart-lib": "/vendor/chart-lib-4/index.js" }
}
}Integrity: subresource integrity for modules
The integrity key attaches hashes to module URLs, so a modified file on a CDN fails to load instead of running. It is supported from Chrome 127, Firefox 138 and Safari 18.
{
"imports": { "preact": "/vendor/preact-10/preact.module.js" },
"integrity": {
"/vendor/preact-10/preact.module.js": "sha384-...hash of the file..."
}
}Step 2: JSON modules with import attributes
Import attributes, standardised in ECMAScript 2025, tell the engine what kind of module to expect. The one that works everywhere is JSON:
// js/app.js
import config from './config.json' with { type: 'json' };
import { formatPrice } from './lib/format.js';
console.log(config.pageSize, formatPrice(1499, config.currency));
// 20 "₹1,499.00"
// dynamic form, for files you need only sometimes
const { default: strings } = await import('./i18n/hi.json', { with: { type: 'json' } });The type attribute is required, not decoration: it stops a server from turning a "JSON" file into executable script. Forgetting it is an error; on Node.js 24 the failure code is ERR_IMPORT_ATTRIBUTE_MISSING, and browsers reject the import as well. JSON imports are supported from Chrome 123, Firefox 138 and Safari 17.2. CSS module scripts (type: 'css') are in Chrome 123 and Firefox 147 but not in Safari, so treat them as progressive enhancement.
A JSON module is evaluated once and shared. Every import of the same URL returns the same object, so treat it as read-only configuration rather than mutable state.
Step 3: kill the waterfall with modulepreload
Without a bundler, the browser discovers dependencies one level at a time: it loads main.js, parses it, finds app/router.js, loads that, and so on. modulepreload lists modules up front. According to MDN, it fetches the module, parses and compiles it, and stores it in the module map ready to run, while plain preload only downloads to the cache.
<link rel="modulepreload" href="/js/main.js">
<link rel="modulepreload" href="/js/router.js">
<link rel="modulepreload" href="/vendor/preact-10/preact.module.js">Two details matter. First, a browser may also fetch a preloaded module's dependencies, but MDN stresses that this is browser-specific, so list every module you want fetched early. Second, the href is a URL, not a bare specifier, so use the resolved path from your import map. modulepreload is supported from Chrome 66, Firefox 115 and Safari 17.

Step 4: cache-busting without a bundler
The import map is the natural place to put versioned file names. Serve files with long cache lifetimes and a hash or version in the path, and change only the map when you deploy:
{
"imports": {
"app/": "/js/2026-10-09.3/",
"preact": "/vendor/preact-10/preact.module.js"
}
}Because the HTML page is usually served with a short cache lifetime, a new map takes effect on the next page load, and unchanged vendor files stay cached. A tiny script that copies /js/ into a versioned folder and rewrites one line of HTML is all the "build" you need.
Inside modules, import.meta.resolve('app/lib/format.js') returns the URL a specifier maps to, which is handy for workers and fetch() calls that need the same versioned path. It is supported from Chrome 105, Firefox 106 and Safari 16.4.
Gotchas
- Map too late. If a module loads before the import map is processed, bare specifiers fail with a resolution error. Keep the map first in
<head>. - Wrong MIME type. Servers must send JavaScript files as
text/javascriptand JSON files asapplication/json; module loading is strict about this. - CORS on CDNs. Module scripts and
modulepreloadalways use CORS requests, so third-party hosts must sendAccess-Control-Allow-Origin. - Packages written for Node.js. A package that imports Node.js built-ins or uses CommonJS will not run in the browser through an import map. Use packages that ship browser-ready ES module builds.
- Too many tiny files. Hundreds of modules still cost requests and parse time. If your dependency graph grows that large, that is the signal to add a bundler.
What to do next
- Pick one page or widget, move its scripts to
type="module"and add an import map for its dependencies. - Load configuration with
with { type: 'json' }instead of a global variable written into the HTML. - Open the Network panel, note the request waterfall, then add
modulepreloadlinks for the first two levels of imports and compare. - Add
integrityhashes for anything served from a third-party CDN.
Frequently asked questions
Do I still need a bundler in 2026?
Not for every project. Import maps, JSON modules and
modulepreloadwork in all current browsers. You still need a build step for TypeScript or JSX, very large dependency graphs, or browsers older than about 2023.Can I load an import map from an external file?
No. MDN states that the
srcattribute must not be specified on an import map, so the JSON has to be inline in the page.Can a page have more than one import map?
In Chrome 133+ and Safari 18.4+, yes; the maps are merged. Firefox still has the feature behind a flag according to MDN's data, so use a single map for broad support.
Why do I need with { type: 'json' } to import a JSON file?
The attribute tells the engine to parse the file as JSON and never execute it. Without it, the import fails, which protects you if a server returns script instead of data.
What is the difference between preload and modulepreload?
preloadonly downloads a file into the cache.modulepreloadfetches, parses and compiles a module and stores it in the module map, ready to execute.Can I use CSS module scripts too?
Only as progressive enhancement. MDN's data lists
type: 'css'imports in Chrome 123 and Firefox 147, but not in Safari.
Your next customer could start right here.
Introduce your tool, product or business to readers exploring technology.
Explore placements and request a private quote
Sources
- <script type="importmap"> — MDN Web Docs
- rel=modulepreload — MDN Web Docs
- import: import attributes — MDN Web Docs
- import.meta.resolve() — MDN Web Docs
- Finished Proposals (Import Attributes, JSON Modules: 2025) — TC39
- MDN browser-compat-data 8.1.5 (data.json) — MDN Web Docs, 2026-10-08
Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.
Comments
No comments yet. Be the first to join the conversation.
Leave a comment
Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.
More from Cloudsoft Zone

TutorialJavaScript
JavaScript using declarations: automatic cleanup without finally
Learn JavaScript's using and await using declarations, Symbol.dispose and DisposableStack, with tested browser recipes and October 2026 support data.
By Cloudsoft Zone

ComparisonJavaScript
structuredClone vs JSON vs spread: deep copy JavaScript safely
Spread is shallow, JSON loses dates, Maps, Sets and undefined, and structuredClone keeps them. A tested comparison of JavaScript copy methods and their limits.
By Cloudsoft Zone

Troubleshooting guideJavaScript
Fix 'Uncaught (in promise)': find every unhandled rejection
Why browsers log Uncaught (in promise), the five code patterns behind most unhandled rejections, how to find them in DevTools, and a safe global handler.
By Cloudsoft Zone
