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.
- 01Find the URLTell relative, absolute, and bare imports apart, and work out the URL each one gives.
- 02Read import mapsUse exact keys, prefix keys, and scopes to give bare names real URLs.
- 03Run browser modules safelyWatch real module scripts and JSON modules run inside a sandboxed iframe.
From an import string to a file
Every import line has a short string in quotes, like "./math.js". That string is called a module specifier. It says which module you want.
Before your code runs, the host must turn that string into a real address. The host is the program that runs your JavaScript, such as a browser or Node. Turning the string into an address is called module resolution.
In a browser, every module lives at a URL. Some specifiers already work as a way to find that URL:
./math.jsmeans "the filemath.jsin the same folder as this file".https://cdn.example/lib.jsis already a full address.
Other specifiers are just names, like react or greeting. A browser has no address for a name. It needs an import map, a list that gives each name a URL. Or a build tool must swap the name for a URL before the browser sees it.
Module resolution is how the host turns an import specifier into the exact URL or file it should load.
If I say "go next door", you need to know where you are standing. If I say "go to 12 Park Road", you can go straight there. If I say "go to Sam's house", you need an address book to find where Sam lives.
- In real life: “Go next door” or “go up one floor”
- In JavaScript: Relative specifiers like
./utils.jsand../data.js - In real life: “Go to 12 Park Road”
- In JavaScript: Absolute URL specifiers like
https://cdn.example/app.js - In real life: “Go to Sam’s house”
- In JavaScript: Bare specifiers like
greetingorreact - In real life: An address book
- In JavaScript: An import map that says which URL a name means
In this lesson you will:
- work out module URLs by hand,
- run real modules inside a sandboxed iframe, which is a small page inside this page that can't touch the lesson,
- compare normal scripts with module scripts,
- load a JSON file as a module,
- decide when a name needs an import map, Node, or a build tool.
Relative, absolute, and bare specifiers
INTERACTIVEA browser sorts every specifier into one of three kinds:
- Relative: it starts with
./,../, or/. It gives directions from the file that does the importing. - Absolute URL: a full address, like
https://cdn.example/lib.js. The browser uses it as written. - Bare: anything else, like
reactorgreeting. It is just a name. The browser has no address for it.
The URL of the file that does the importing is called the referrer. For a relative specifier, the browser joins the specifier to the referrer. It uses the same rule as new URL(specifier, referrer), which you met in the URL Objects lesson. A leading / means "start from the top of this website".
./utils.js is relative, so the browser joins it to the URL of the file that imports it. From https://app.example/src/main.js you get https://app.example/src/utils.js.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 list gives this bare name a real URL.
./local.js, /assets/app.js, lodash/fp/map.js, and react.Try these in the lab, one at a time:
./utils.js: the result sits in the same folder as the referrer,/src/./assets/app.js: the result starts from the top of the site,https://app.example/.greetingwith the import map off: it fails. The browser can't find any URL to fetch. The problem is not a missing export.
const referrer = "https://app.example/src/main.js";const moduleUrl = new URL("./chart.js?theme=dark#legend", referrer);console.log(moduleUrl.pathname);console.log(moduleUrl.search + moduleUrl.hash);Line 2 joins ./chart.js?theme=dark#legend to the referrer. Line 3 prints the path, /src/chart.js. Line 4 prints the rest, ?theme=dark#legend. The part that starts with ? is the query. The part that starts with # is the hash.
Resolution keeps both parts on the module URL. It does not throw them away. They stay part of the module's address.
./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
Put each import string in the group that can handle it. Each answer says why.
Import maps: give a bare name a URL
STEP THROUGHAn import map is a short piece of JSON inside a <script type="importmap"> tag. It tells the browser which URL each bare name means. It has two parts:
importsgives a name a URL. The keygreetingmatches only the exact namegreeting.scopesgives a different answer to files in one folder. You'll see it below.
A key that ends in / is a prefix key. It matches every name that starts with it. For example, lodash/ matches lodash/fp/map.js. The browser keeps the rest of the name, fp/map.js, and adds it to the end of the mapped URL.
<script type="importmap">{ "imports": { "greeting": "./modules/greeting.js", "lodash/": "https://cdn.example/lodash-es/" }, "scopes": { "/admin/": { "greeting": "./admin/greeting.js" } }}</script>- Line 1 opens the import map.
- Line 4 gives the name
greetingthe URL./modules/greeting.js. - Line 5 is a prefix key. Every name that starts with
lodash/goes tohttps://cdn.example/lodash-es/. - Lines 7 to 9 are a scope. Files whose URL starts with
/admin/get./admin/greeting.jsinstead.
greeting a real URL. The browser looks the name up in the map, then loads ./modules/greeting.js.A scope depends on which file is importing. The browser looks at the importing file's URL. If that URL starts with a scope key, like /admin/, the browser checks that scope first. If the name isn't there, it uses the top-level imports.
At home, you look up a name in your own contact list. At work, you check the office phone list first. It may have a different number for the same name.
- In real life: Your own contact list
- In JavaScript: Top-level
importsgivegreetingone URL for every file - In real life: The office phone list
- In JavaScript: A
scopesentry givesgreetinganother URL for files in/admin/ - In real life: Every name that starts with “Dr.”
- In JavaScript: A prefix key matches every name that starts with it
greeting. A file under /admin/ gets the scope's answer, and every other file gets the answer from the top-level imports.<script type="importmap">{ "imports": { "ui/": "/vendor/ui/", "ui/forms/": "/vendor/forms/" }}</script>Both keys start with ui/, so both match ui/forms/input.js. The browser picks the longer key, ui/forms/. It keeps the rest of the name, input.js, and adds it to /vendor/forms/. The result is /vendor/forms/input.js.
An exact key always beats a prefix key. Among prefix keys, the longest one wins.
Next, step through a small module. It imports one relative file and one bare name. Switch the import map on and off.
Switch the import map on and off. Step through to see the same code connect or fail.
script
import { add } from "./math.js";import { greeting } from "greeting";console.log(greeting, add(2, 3));This step-through is a model of resolution, not a real debugger. It uses the same resolver as the lab above. The lesson's tests check that every URL it shows matches what new URL really returns.
One part works exactly like a real browser. The entry file and every file it imports form a module graph. The browser resolves every import line in the graph before any code runs. If even one specifier can't be resolved, nothing in the entry file runs.
<script type="importmap">{"imports":{"greeting":"data:..."}}</script><script type="module">import { text } from "greeting";parent.postMessage({ text }, "*");</script>Pick a button to build the iframe.
The module code is real. It runs only inside a sandboxed iframe, and this page accepts messages only from that iframe. The import map must come before the module script that needs it.
The lab above runs a real type="module" script in an iframe. Here is what each button shows:
- No import map:
import "greeting"fails with the browser's "Failed to resolve module specifier" TypeError. - Map before the module: the import works.
- Map after the module: the import fails. The map came too late for it.
Module scripts in the browser
INTERACTIVEA classic script is a normal <script> tag. A module script is a <script type="module"> tag. A module script is not just a classic script that can use import. It behaves differently in five ways:
- It waits until the browser has read the whole HTML page. This is called being deferred.
- It always runs in strict mode. So a mistake like setting a variable you never declared throws an error.
- Its top-level variables stay inside the module. They do not become properties of
window. - It loads its imports as a module graph. Each module runs only once per URL.
- If it comes from another website, it uses CORS. That means the other server must say your page may load it. The CORS lesson explains this rule.
There is also <script nomodule>. It is a backup for very old browsers that don't understand modules. Browsers that do understand modules skip it.
Read each row of the table as one feature. Compare the two kinds of script.
| What we compare | Classic script | Module script |
|---|---|---|
| When it runs | It runs as soon as the browser reaches it, unless you add defer or async. | It waits until the browser has read the whole page. |
| Strict mode | It is not strict unless the file says "use strict". | It is always strict, with no extra line needed. |
| Top-level variables | A top-level var becomes a property of window. | Top-level variables stay inside the module. |
| Files from other websites | Older, looser rules apply. | CORS applies, so the other server must allow it. |
The nomodule backup | Very old browsers run nomodule scripts, because they ignore type="module". | Browsers that understand modules skip nomodule scripts. |
<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 iframe to report.
The classic script runs right away, so it can't see the paragraph below it yet. The module waits for the page and runs in strict mode. It imports the same URL twice, but that module runs only once. With async, a module runs as soon as its imports are ready.
nomodule backup.The lab needs a real HTML page, real module code, and a real nomodule tag. An iframe gives it all three without touching the modules of this lesson page.
This page only accepts messages that come from the iframe it made. The Windows, iframes & postMessage lesson uses the same safety check.
JSON modules and import attributes
INTERACTIVEA JSON module is a JSON file loaded with import. The module's default export is the parsed data. So you get a normal JavaScript object, not a string of text.
Modern browsers need an import attribute on that import: with { type: "json" }. It is a label that says "this import is JSON". Without the label, the browser expects JavaScript, so it refuses the JSON file.
A label on a parcel tells the person who receives it what is inside and how to handle it. If the label is wrong or missing, they can refuse the parcel.
- In real life: A parcel labeled “glass”
- In JavaScript:
with { type: "json" }says the import is JSON - In real life: The receiver refuses a parcel with the wrong label
- In JavaScript: The browser refuses a JSON file imported without the right attribute
- In real life: Opening the parcel shows what is inside
- In JavaScript: The default export is the parsed JSON data
with { type: "json" }, the browser loads the JSON file and gives you the parsed object as data. Without the attribute, the browser refuses the file.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 that says this import is JSON. Modern Chrome refuses the same JSON file without that label. Check support in the browsers you target.
This ties back to the JSON lesson: JSON is data, not code. A JSON module gives you the parsed data without calling JSON.parse yourself. The browser still does the fetching, the CORS check, and the type check.
Node has its own rules for packages
NODE ONLYBrowsers stop after URL rules and import maps. Node, which runs JavaScript outside the browser, has more rules for packages. A package is a folder of code that you install, like chalk. When Node sees a bare name such as chalk:
- It looks for a
chalkfolder insidenode_modules, the folder where installed packages live. - It reads that package's
package.jsonfile. - The
package.jsonfile says which file is the public entry.
Two more rules matter for ES modules (ESM), the kind of module that uses import and export:
"type": "module"inpackage.jsontells Node that normal.jsfiles in this package are ES modules.- Write the file extension on relative imports:
./math.js, not./math. Node ESM does not guess a missing extension.
{ "type": "module", "exports": { ".": { "import": "./dist/index.mjs", "require": "./dist/index.cjs", "browser": "./dist/browser.js" } }, "imports": { "#config": "./src/config.js" }}- Line 2 sets
"type": "module". - Lines 3 to 9 are
exports, the list of public entry files. The key"."on line 4 means "the package itself". - Lines 5 to 7 are conditions. Each one picks a file for one way of loading the package.
- Line 10 is
imports. It sets up a private short name,#config.
Node's usual conditions are import, for an import statement, and require, for require() in CommonJS, Node's older module system. Other tools, like bundlers, may use other conditions such as browser.
// Browser and Node ESM do not guess this extension.import { add } from "./math.js";console.log(add(2, 3));// In a package with "imports": { "#config": "./src/config.js" }import config from "#config";console.log(config.apiBaseUrl);#config is not a package name. Node can find it only from code inside the package whose imports field defines it. A browser can't use this name unless a build tool changes it first.
Read each row of the table as one host: what it does with a package name, and what it does with a relative file.
| Who runs the code | With a package name | With a relative file |
|---|---|---|
| Browser | It needs an import map, or a URL that a build tool wrote. | It uses the URL as written. It doesn't add .js or search folders. |
| Node ESM | It finds the package, then follows its exports. | You must write the file extension yourself. |
| Bundler | It finds packages, short names, and conditions while it builds. | It may add an extension or pick another file before it writes the output. |
Node also has import.meta.resolve(). You give it a specifier. It tells you the URL that specifier would resolve to from the current module. It only reports the URL. It does not load or run the module. Use it to find out why an import fails, not in place of a normal import.
Where you'll use this
You'll meet module resolution in three everyday places:
- Small demos: you write browser modules directly and add an import map for a few names.
- Real apps: a bundler is a build tool that joins your files before you publish them. It often swaps package names for real file URLs. The file names often have a short code in them, like
react-8f31.js. - Node: Node finds package names in
node_modulesand followspackage.jsonexports. The next lesson shows how CommonJS and ES modules work together 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.Lines 1 and 2 import two packages by name. Lines 4 to 6 say what each host does with those names.
A bundler is a build tool, not a browser feature. It may read package exports, use a short name you set up, pick the browser condition, and write out a new file name. The browser only sees the final result.
import React from "react";import Button from "@company/ui/button"; // A bundler resolves these names and emits browser URLs.- Browser without tools: use relative URLs, full URLs, and the names you list in an import map.
- App with a bundler: write package names like
react. The bundler finds them and rewrites them before the browser sees the code. - Node scripts: Node uses its package rules, such as
exports,imports, and conditions. Browsers don't have these rules.
Common misconceptions
"A slash anywhere makes it relative."
No. lodash/fp has a slash, but it is still bare. Only the start counts. A relative specifier starts with ./, ../, or /. A full URL starts with a scheme like https:.
"Import maps install packages."
No. They only give names URLs. The files must already be at those URLs. The browser still fetches them with the normal module rules.
"Scopes depend on the name being imported."
No. Scopes depend on the URL of the file that does the importing. The same name can give one URL in a file under /admin/ and another URL in a file under /public/.
"Module scripts run just like classic scripts."
No. Modules wait for the page, run in strict mode, keep their variables to themselves, load as a graph, and run once per URL. Habits from classic scripts can trip you up.
"A JSON import is just JSON.parse with nicer code."
Not quite. You do get parsed data. But the browser also checks the import attribute and the file type before it gives you the data.
"The browser will add .js for me."
No. Browsers use the URL exactly as you wrote it. Node ESM also needs the extension on relative imports. Only a bundler may add one, while it builds your app.
"exports and imports are two names for the same thing."
No. exports lists the public entry files of a package. imports sets up private # names for code inside that package.
"If a bundler can find it, the browser can find it too."
No. The bundler rewrites the import before the browser gets the code. The browser only resolves the final code it receives.
Practice: resolve before you run
6 EXERCISESPredict the full URL this program prints.
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. The browser uses this same rule for a relative import.
A page has <script type="module">import "greeting"</script>. Below it is an import map. What must move?
Move the <script type="importmap"> above the <script type="module">. A map that comes later is too late for that import.
This object is shaped like the data a JSON module gives you. What does the program 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 object has course and lesson, so the output is JavaScript: Module resolution.
Which greeting would a file inside /admin/ get?
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);The referrer starts with the admin scope, so the scope's greeting beats the top-level greeting.
Open a file of your own in your editor. Next to each import, write "browser can find it" or "needs a map or tool". Then explain one choice to a friend.
import "./setup.js"; // browser finds it from this file
import "react"; // needs a bundler, Node, or an import map
import "/theme/colors.js"; // browser finds it from the top of the site
import "lodash/fp/map.js"; // bare: needs a prefix key or a toolOnly the first characters decide whether the browser can find it alone. A slash in the middle, as in lodash/fp/map.js, does not make it relative.
Which file does this package pick for an import statement?
const packageJson = { type: "module", exports: { import: "./index.mjs", require: "./index.cjs" } };
const condition = "import";
console.log(packageJson.exports[condition]);const packageJson = { type: "module", exports: { import: "./index.mjs", require: "./index.cjs" } };
const condition = "import";
console.log(packageJson.exports[condition]);The condition is import, so the package picks ./index.mjs. A browser would not read package.json on its own.
Quiz: check your understanding
8 QUESTIONSThese questions cover the kinds of specifier, relative URLs, import maps, module scripts, JSON imports, and Node.
Question 1 of 8Which specifier can a browser turn into a URL without an import map?
Choose an answer to see the explanation.
Question 2 of 8What URL does this program print?
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 8In an import map, what is special about a key that ends in
/?Choose an answer to see the explanation.
Question 4 of 8Which scope does the browser use for an import?
Choose an answer to see the explanation.
Question 5 of 8Which sentence about module scripts in the browser is true?
Choose an answer to see the explanation.
Question 6 of 8This object has the same shape as the data a JSON module gives you. What does the program print?
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 8Why write
with { type: "json" }on a JSON import?Choose an answer to see the explanation.
Question 8 of 8Which sentence about hosts is true?
Choose an answer to see the explanation.
Key takeaways
- A relative specifier gives directions from the URL of the importing file.
- An absolute URL specifier is already the full address.
- A bare name needs an import map in the browser, or a bundler or Node to find it.
- An import-map key that ends in
/matches every name that starts with it. A scope is picked by the URL of the importing file. - Module scripts wait for the page, run in strict mode, keep their variables to themselves, use CORS for other websites, and run once per URL.
- A JSON module needs
with { type: "json" }. Its default export is the parsed data. - Node uses
package.jsonfields:type,exports, and privateimportsnames. - A bundler can swap package names for real URLs before the browser gets the code.
Remember the one-liner.
Module resolution is how an import string becomes the exact URL or file the host loads.
Up next: CommonJS & ES module interop. You'll see how Node's require, "type": "module", and package folders work together with import.