Module resolution & import maps
Learn how JavaScript module specifiers become URLs in the browser, how import maps give bare names addresses, how module scripts load, and why JSON modules need import attributes.
- 01Resolve specifiersClassify relative, absolute, and bare imports and predict their resolved URLs.
- 02Read import mapsUse exact keys, prefix keys, and scopes without treating them like a package manager.
- 03Run browser modules safelyObserve real module scripts and JSON modules inside sandboxed iframes.
From a string to a module file
Every import statement contains a small string called a module specifier. Before JavaScript can run your code, the host has to answer one practical question: “What file or URL does this string mean?” That answer is module resolution.
Browser modules are deliberately URL-shaped. If the specifier looks like directions from the current file, such as ./math.js, or a full address, such as https://cdn.example/lib.js, the browser can resolve it directly. If it is just a package-like name, such as react or greeting, the browser needs an import map or a build step to translate the name into a URL first.
Module resolution is the host’s process for turning an import specifier into the exact URL or file that should be loaded.
If I say “go next door,” that instruction depends on where you are standing. If I say “go to the library,” you need an address book or local knowledge. Imports work the same way: some strings are directions, some are addresses, and some are names that need a map.
- In real life: “Next door” or “up one floor”
- In JavaScript: Relative specifiers like
./utils.jsand../data.js - In real life: “The library”
- In JavaScript: Bare specifiers like
greetingorreact - In real life: A street address
- In JavaScript: Absolute URL specifiers like
https://cdn.example/app.js - In real life: An address book
- In JavaScript: An import map that says which URL a name means
Where the analogy stops: Directions only make sense from where you are standing. Bare names only make sense after the host has an address book, a package resolver, or a bundler rewrite.
In this lesson you will resolve specifiers by hand, run real modules inside sandboxed iframes, compare classic scripts with module scripts, import JSON with an attribute, and practice deciding when a name needs the browser, an import map, Node, or a bundler.
Relative, absolute, and bare specifiers
INTERACTIVEBrowsers use three broad buckets. A relative specifier starts with ./, ../, or /. It is resolved with the same URL rules you practiced in the URL Objects lesson: new URL(specifier, referrer). An absolute URL already names the address. A bare specifier is everything else: a name with no built-in browser address.
const referrer = "https://app.example/src/main.js";resolve("./utils/math.js", referrer);resolve("https://cdn.example/lib.js", referrer);resolve("greeting", referrer);resolve("lodash/fp/map.js", referrer);baredata:text/javascript,export%20const%20message%20%3D%20%22Hello%20from%20a%20mapped%20module%22%3Bgreeting, lodash/, apphttps://app.example/admin/The import map's imports table maps the bare name to a real URL.
./local.js, /assets/app.js, lodash/fp/map.js, and react.Try ./utils.js first. The result stays next to the importing module. Try /assets/app.js next: it jumps to the origin root. Then try greeting with the import map off. The failure is not about exporting; the browser cannot even find a URL to fetch.
./utils/math.js../shared/log.js/assets/app.jshttps://cdn.example/preact.jsdata:text/javascript,export const ok = truegreetinglodash/fp/map.jsreact@scope/pkg/button
Place each import specifier where a browser or tool would handle it. Each answer explains why.
Import maps: the browser’s address book
STEP THROUGHAn import map is JSON inside <script type="importmap">. Its imports object maps bare names to real URLs. A key ending in / is a prefix key: lodash/ can match lodash/fp/map.js and keep the fp/map.js tail. A scopes object can override entries for modules imported from a particular URL neighborhood.
Your phone can use one contact list, while work uses its own directory. Import-map scopes work the same way: the importing module’s URL decides which directory to check first.
- In real life: Your contacts list
- In JavaScript: Top-level
importsmapgreetingto a shared URL - In real life: The office directory
- In JavaScript: A
scopesentry mapsgreetingdifferently for/admin/modules - In real life: Names beginning with the same word
- In JavaScript: A prefix key maps every matching subpath
Where the analogy stops: Import maps do not install packages or scan folders. They only translate strings the browser sees.
<script type="importmap">{ "imports": { "greeting": "./modules/greeting.js", "lodash/": "https://cdn.example/lodash-es/" }, "scopes": { "/admin/": { "greeting": "./admin/greeting.js" } }}</script>Toggle the import map and step through how the same source either links or fails.
script
import { add } from "./math.js";import { greeting } from "greeting";console.log(greeting, add(2, 3));The replay is a model of resolution, not an engine debugger. It uses the same pure resolver as the lab above, and the tests prove the displayed URLs match real URL behavior. The important order is real: if any static import in a module graph cannot resolve, the graph fails to link before the entry module runs.
<script type="importmap">{"imports":{"greeting":"data:..."}}</script><script type="module">import { text } from "greeting";parent.postMessage({ text }, "*");</script>Choose a mode to build the sandboxed frame.
The module code is real, but it runs only inside a sandboxed srcdoc iframe. The parent accepts messages only from that frame. Import maps must be parsed before the module script that needs them.
The iframe lab runs a real type="module" script. With no import map, import "greeting" fails with the browser’s “Failed to resolve module specifier” TypeError. With the map before the module, it succeeds. With the map after the module, it is too late for that import.
Module scripts in the browser
INTERACTIVEBrowser module scripts are not just classic scripts with imports. They are deferred by default, always strict, keep their top-level declarations out of the global object, fetch dependencies as a graph, and run each module once per URL. Cross-origin module scripts and imports use CORS; the server must allow the module response. The CORS lesson explains the network rule in detail.
| Feature | Classic script | Module script |
|---|---|---|
| Default timing | Inline classic scripts run when the parser reaches them unless defer or async is added. | Modules are deferred by default: the document is parsed before they execute. |
| Strict mode | Sloppy unless the file says "use strict". | Always strict, even without a directive. |
| Top-level names | Top-level var can become a global property. | Top-level declarations stay in the module's own scope. |
| Fetching | Cross-origin classic scripts have older, looser rules. | Cross-origin module scripts use CORS, so the server must allow the fetch. |
| Fallback | Browsers that understand modules ignore nomodule scripts. | Older browsers run nomodule fallbacks because they ignore type="module". |
<script>classic code runs while parsing</script><script type="module">import { count as first } from "data:...";import { count as second } from "data:...";undeclaredModuleValue = 1; // throws in strict modulesparent.postMessage({ first, second }, "*");</script><script nomodule>fallback for old browsers</script><p id="target">parsed after scripts</p>Waiting for the frame to report.
The classic inline script runs immediately and cannot see the later paragraph. The module is deferred, strict, has its own scope, and importing the same URL twice reuses one module instance. async lets a module run as soon as its graph is ready.
nomodule fallback.The lab uses an iframe because it needs a real HTML parser, real module execution, and real nomodule behavior without touching this lesson page’s own module graph. The parent checks that messages come from the iframe it created, just like the sandboxed frame patterns in the Windows, iframes & postMessage lesson.
JSON modules and import attributes
INTERACTIVEA JSON module imports a JSON resource and exposes the parsed value as its default export. Modern browsers require an import attribute: with { type: "json" }. The attribute is a label on the package saying what is inside, so the browser can reject a response that does not match the expected module type.
A delivery label tells the receiver what handling rules to apply. Import attributes do the same for module loading: this import expects JSON, not ordinary JavaScript.
- In real life: A box labeled “glass”
- In JavaScript:
with { type: "json" }says the import expects JSON - In real life: The receiver refuses a mislabeled package
- In JavaScript: The browser refuses a JSON module imported without the right attribute
- In real life: Opening the box reveals the item
- In JavaScript: The default export is the parsed JSON value
Where the analogy stops: The label does not convert JavaScript into JSON. The fetched resource still has to be valid JSON, and support should be checked in the browsers you ship to.
import data from "data:application/json,%7B%22course%22%3A%22JavaScript%22%2C%22lesson%22%3A%22Module%20resolution%22%7D" with { type: "json" };console.log(data.course);console.log(data.lesson);Waiting for the JSON module.
The with { type: "json" } attribute is a label: this import expects JSON. Modern Chrome refuses the same data: JSON module without that label. Check support in the browsers you target.
This connects to the earlier JSON lesson: JSON is data, not code. A JSON module gives you the parsed data value without calling JSON.parse yourself, but the module loader still controls fetching, CORS, and type checks.
Where you’ll use this
You will see module resolution in three daily places. In small demos, you can write browser-native modules directly and use an import map for a few friendly names. In production apps, a bundler often rewrites bare package specifiers into hashed URLs. In Node, bare package names are resolved through node_modules and package.json exports; the next lesson covers CommonJS and ES module interop there.
import React from "react";import button from "@company/design-system/button"; // Browser: needs import map entries or bundled URLs.// Node: searches node_modules and package.json "exports".// Bundler: rewrites these imports during the build.- Browser without tools: use relative URLs, absolute URLs, and explicit import map entries.
- Bundled app: write package names like
react; the bundler resolves and rewrites them before the browser sees the code. - Node scripts: Node applies its package-resolution rules. That is powerful, but it is not what browsers do natively.
Common misconceptions
“A slash anywhere means relative.”
No. lodash/fp has a slash but is still bare because it does not start with ./, ../, /, or a URL scheme.
“Import maps install packages.”
They only map strings to URLs. You still need files at those URLs, and the browser still fetches them with normal module rules.
“Scopes depend on the imported package name.”
Scopes depend on the importing module’s URL. The same bare name can resolve differently from /admin/ than from /public/.
“Module scripts run exactly like classic scripts.”
Modules are deferred, strict, scoped, fetched as a graph, and cached per URL. Classic-script habits can mislead you.
“JSON imports are just JSON.parse with nicer syntax.”
The result is parsed data, but the browser’s module loader also checks the import attribute and module type.
Practice: resolve before you run
5 EXERCISESPredict the full URL printed by this program.
const referrer = "https://app.example/src/pages/home.js";
console.log(new URL("../shared/card.js", referrer).href);const referrer = "https://app.example/src/pages/home.js";
console.log(new URL("../shared/card.js", referrer).href);new URL gives https://app.example/src/shared/card.js. Browser relative module resolution uses this same URL idea.
A page has <script type="module">import "greeting"</script> and then an import map below it. What must move?
Move the <script type="importmap"> before the <script type="module">. Import maps are not retroactive.
What does this JSON-module-shaped value print?
const data = { course: "JavaScript", lesson: "Module resolution" };
console.log(data.course + ": " + data.lesson);const data = { course: "JavaScript", lesson: "Module resolution" };
console.log(data.course + ": " + data.lesson);The parsed object has course and lesson, so the output is JavaScript: Module resolution.
Which mapping would an /admin/ module use?
const scope = "https://app.example/admin/";
const referrer = "https://app.example/admin/panel.js";
const chosen = referrer.startsWith(scope) ? "admin greeting" : "regular greeting";
console.log(chosen);const scope = "https://app.example/admin/";
const referrer = "https://app.example/admin/panel.js";
const chosen = referrer.startsWith(scope) ? "admin greeting" : "regular greeting";
console.log(chosen);Because the referrer is inside the admin scope, the scoped greeting wins over the top-level greeting.
In your editor, label each import in a file as “browser URL-like” or “needs a map/tool.” Explain one decision to a teammate.
import "./setup.js"; // browser resolves directly
import "react"; // bundler, Node, or import map needed
import "/theme/colors.js"; // browser resolves from origin root
import "lodash/fp/map.js"; // bare; prefix map or tool neededThe leading characters decide browser-native resolution. A slash in the middle, as in lodash/fp/map.js, does not make it relative.
Quiz: check your understanding
7 QUESTIONSThese questions mix classification, resolution, import maps, module scripts, and JSON import attributes.
Question 1 of 7Which specifier can a browser resolve directly without an import map?
Choose an answer to see the explanation.
Question 2 of 7What URL does this relative module specifier produce?
Read the code, then predictconst referrer = "https://app.example/src/main.js"; console.log(new URL("../shared/log.js", referrer).href);Choose an answer to see the explanation.
Question 3 of 7In an import map, what is special about a key ending in
/?Choose an answer to see the explanation.
Question 4 of 7What does the scoped import map entry depend on?
Choose an answer to see the explanation.
Question 5 of 7Which statement about browser module scripts is true?
Choose an answer to see the explanation.
Question 6 of 7What does this JSON-module-like object log?
Read the code, then predictconst data = { course: "JavaScript", lesson: "Module resolution" }; console.log(data.course);Choose an answer to see the explanation.
Question 7 of 7Why write
with { type: "json" }on a JSON module import?Choose an answer to see the explanation.
Key takeaways
- Relative specifiers are directions from the importing module URL.
- Absolute URL specifiers already name the module address.
- Bare specifiers need an import map in browsers, or a bundler/Node-style resolver before the browser sees them.
- Import-map keys ending in
/match prefixes; scopes are chosen by the importing module’s URL. - Module scripts are deferred, strict, scoped, CORS-fetched across origins, and run once per URL.
- JSON modules need
with { type: "json" }and export parsed JSON as the default value.
Remember the one-liner.
Module resolution is how an import string becomes the exact module URL or file the host loads.
Up next: CommonJS & ES module interop, where Node’s require, type: "module", and package boundaries enter the story.