Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
JavaScriptArchitecture guide

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.

Cloudsoft Zone

7 min read

Pile of Lego bricks in assorted colours and sizes including red, yellow, green, blue and orange
Photograph: Alan Chia / CC BY-SA 2.0, via Wikimedia Commons
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 --noEmit without 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.
ConcernBundled appNative modules + import map
Build stepRequiredNone (optional hashing step)
Bare imports such as "preact"Resolved by the bundlerResolved by the import map
Number of requestsFew, large filesOne per module; HTTP/2 or HTTP/3 and modulepreload keep this fast
Cache invalidationHashed file namesHashed names mapped in the import map
TypeScript, JSXCompiledNot 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:

JavaScript
<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, scopes and integrity.
  • 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 src attribute 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 first type="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.

Rows of dark wooden card catalogue drawers with brass label holders and handles
An import map works like a catalogue: it looks up a name such as preact and returns the location of the file.

Scopes: two versions of one package

scopes remaps a specifier only for modules under a given path, which helps during a gradual upgrade:

JavaScript
{
  "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.

JavaScript
{
  "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:

JavaScript
// 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.

JavaScript
<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.

Chromium developer tools Network panel open beside a web page, showing request and response headers for one file
The Network panel shows whether modules load in a waterfall or in parallel after you add modulepreload links.

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:

JavaScript
{
  "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/javascript and JSON files as application/json; module loading is strict about this.
  • CORS on CDNs. Module scripts and modulepreload always use CORS requests, so third-party hosts must send Access-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 modulepreload links for the first two levels of imports and compare.
  • Add integrity hashes 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 modulepreload work 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 src attribute 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?

    preload only downloads a file into the cache. modulepreload fetches, 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.

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. <script type="importmap"> — MDN Web Docs
  2. rel=modulepreload — MDN Web Docs
  3. import: import attributes — MDN Web Docs
  4. import.meta.resolve() — MDN Web Docs
  5. Finished Proposals (Import Attributes, JSON Modules: 2025) — TC39
  6. 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.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.