completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

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.

By the end, you can
  • 01
    Find the URLTell relative, absolute, and bare imports apart, and work out the URL each one gives.
  • 02
    Read import mapsUse exact keys, prefix keys, and scopes to give bare names real URLs.
  • 03
    Run 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.js means "the file math.js in the same folder as this file".
  • https://cdn.example/lib.js is 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.

The short definition

Module resolution is how the host turns an import specifier into the exact URL or file it should load.

Real-life analogyDirections, addresses, and an address book

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.js and ../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 greeting or react
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

INTERACTIVE

A 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 react or greeting. 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".

A card ./utils.js, the specifier, plus a card /src/main.js, the referrer, make a card /src/utils.js, the result.new URL("./utils.js", referrer)./utils.jsthe specifier+/src/main.jsthe referrer/src/utils.jsthe result
./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.
Resolver lab: turn a specifier into a URL
Four specifiers to resolvePop out in the code editor (opens in a new tab)JavaScript
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);
Resultbare
statusresolved
kindbare
urldata:text/javascript,export%20const%20message%20%3D%20%22Hello%20from%20a%20mapped%20module%22%3B
map importsgreeting, lodash/, app
scopehttps://app.example/admin/
Try it yourself

The import map's imports list gives this bare name a real URL.

This is a small, tested copy of the browser's rules. Relative and full URLs work straight away. Bare names are looked up in the import map. Try ./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/.
  • greeting with the import map off: it fails. The browser can't find any URL to fetch. The problem is not a missing export.
The ? and # parts stay on the URLPop out in the code editor (opens in a new tab)JavaScript
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.

Sort the specifiers
  • ./utils/math.js
  • ../shared/log.js
  • /assets/app.js
  • https://cdn.example/preact.js
  • data:text/javascript,export const ok = true
  • greeting
  • lodash/fp/map.js
  • react
  • @scope/pkg/button
Try it yourself
0 of 9 correct

Put each import string in the group that can handle it. Each answer says why.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Import maps: give a bare name a URL

STEP THROUGH

An 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:

  • imports gives a name a URL. The key greeting matches only the exact name greeting.
  • scopes gives 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.

An import map with imports and scopesPop out in the code editor (opens in a new tab)HTML
<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 greeting the URL ./modules/greeting.js.
  • Line 5 is a prefix key. Every name that starts with lodash/ goes to https://cdn.example/lodash-es/.
  • Lines 7 to 9 are a scope. Files whose URL starts with /admin/ get ./admin/greeting.js instead.
A tag greeting, a bare name, points to a cart called import map that holds a greeting card. An arrow from the cart leads to a card ./modules/greeting.js, a real URL.greetingbare namegreetingimport map./modules/greeting.jsa real URL
Line 4 of the import map gives the bare name 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.

Real-life analogyYour contact list and the office phone list

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 imports give greeting one URL for every file
In real life: The office phone list
In JavaScript: A scopes entry gives greeting another 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
Two rows. Top: the file /admin/panel.js imports greeting and gets ./admin/greeting.js from the admin scope. Bottom: the file /src/main.js imports greeting and gets ./modules/greeting.js from the top-level imports.file that imports"greeting" means/admin/panel.js./admin/greeting.jsadmin scope/src/main.js./modules/greeting.jstop-level imports
Both files import greeting. A file under /admin/ gets the scope's answer, and every other file gets the answer from the top-level imports.
The longest matching prefix winsHTML
<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.

Step through a small module graph
Step 0 of 4Ready
Your turn: follow the blue line

Switch the import map on and off. Step through to see the same code connect or fail.

Running in
  1. script
Next: line 1
import { add } from "./math.js";import { greeting } from "greeting";console.log(greeting, add(2, 3));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
Import map setting
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

Bare name lab: a real module import in an iframe
<script type="importmap">{"imports":{"greeting":"data:..."}}</script><script type="module">import { text } from "greeting";parent.postMessage({ text }, "*");</script>
Sandboxed iframenot built yet
  • Pick a button to build the iframe.
Try it yourself

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 greeting module is a fixed data: URL module. No code you type is run, and this lesson page never calls import() itself.
Import maps must come first

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

INTERACTIVE

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

How a classic script and a module script differ
What we compareClassic scriptModule script
When it runsIt 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 modeIt is not strict unless the file says "use strict".It is always strict, with no extra line needed.
Top-level variablesA top-level var becomes a property of window.Top-level variables stay inside the module.
Files from other websitesOlder, looser rules apply.CORS applies, so the other server must allow it.
The nomodule backupVery old browsers run nomodule scripts, because they ignore type="module".Browsers that understand modules skip nomodule scripts.
Module script lab: waits, strict, private, runs once
How a module script behavesHTML
<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>
What the iframe reportsnormal
  1. Waiting for the iframe to report.
Try it yourself

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.

Modules from other websites need CORS. This lab avoids the network by using data: URL modules. Modern browsers also skip the 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

INTERACTIVE

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

Real-life analogyA label on a parcel

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
Left: with type json, the import loads, and the cart data holds two cards, course and lesson. Right: without the attribute, the JSON file is refused and the module fails.with { type: "json" }loadscourselessondatadefault exportwithout the attributerefusedJSON filethe module fails
With 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.
JSON module lab: the import attribute
Import a JSON modulePop out in the code editor (opens in a new tab)JavaScript
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);
What the iframe reportswith
  • Waiting for the JSON module.
Try it yourself

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.

The JSON file is a fixed data: URL. With the attribute, its default export is the parsed object.

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 ONLY

Browsers 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:

  1. It looks for a chalk folder inside node_modules, the folder where installed packages live.
  2. It reads that package's package.json file.
  3. The package.json file 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" in package.json tells Node that normal .js files 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.
A package.json with exports and importsJSON
{  "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.

Write the file extensionJavaScript
// Browser and Node ESM do not guess this extension.import { add } from "./math.js";console.log(add(2, 3));
Use a private # name inside a packageJavaScript
// 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.

What each host does with an import
Who runs the codeWith a package nameWith a relative file
BrowserIt 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 ESMIt finds the package, then follows its exports.You must write the file extension yourself.
BundlerIt 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_modules and follows package.json exports. The next lesson shows how CommonJS and ES modules work together there.
Two package imports and three hostsPop out in the code editor (opens in a new tab)JavaScript
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.

Imports a bundler will rewriteJavaScript
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 EXERCISES
Exercise 1 · Warm-upResolve a relative import

Predict the full URL this program prints.

Starter codePop out in the code editor (opens in a new tab)JavaScript
const referrer = "https://app.example/src/pages/home.js";
console.log(new URL("../shared/card.js", referrer).href);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeFix the import map order

    A page has <script type="module">import "greeting"</script>. Below it is an import map. What must move?

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeRead the data from a JSON module

      This object is shaped like the data a JSON module gives you. What does the program print?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      const data = { course: "JavaScript", lesson: "Module resolution" };
      console.log(data.course + ": " + data.lesson);

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticePick the scope

        Which greeting would a file inside /admin/ get?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        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);

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeReview a list of imports

          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.

            Exercise 6 · ChallengePick the Node entry file

            Which file does this package pick for an import statement?

            Starter codePop out in the code editor (opens in a new tab)JavaScript
            const packageJson = { type: "module", exports: { import: "./index.mjs", require: "./index.cjs" } };
            const condition = "import";
            console.log(packageJson.exports[condition]);

            Answer, then press Check. Spacing and letter case don’t matter.

              Quiz: check your understanding

              8 QUESTIONS

              These questions cover the kinds of specifier, relative URLs, import maps, module scripts, JSON imports, and Node.

              Lesson quiz · 8 questionsScore: first tries count
              1. Question 1 of 8Which specifier can a browser turn into a URL without an import map?

                Choose an answer to see the explanation.

              2. Question 2 of 8What URL does this program print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const referrer = "https://app.example/src/main.js";
                console.log(new URL("../shared/log.js", referrer).href);

                Choose an answer to see the explanation.

              3. Question 3 of 8In an import map, what is special about a key that ends in /?

                Choose an answer to see the explanation.

              4. Question 4 of 8Which scope does the browser use for an import?

                Choose an answer to see the explanation.

              5. Question 5 of 8Which sentence about module scripts in the browser is true?

                Choose an answer to see the explanation.

              6. 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 predictPop out in the code editor (opens in a new tab)JavaScript
                const data = { course: "JavaScript", lesson: "Module resolution" };
                console.log(data.course);

                Choose an answer to see the explanation.

              7. Question 7 of 8Why write with { type: "json" } on a JSON import?

                Choose an answer to see the explanation.

              8. 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.json fields: type, exports, and private imports names.
              • 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.

              CompleteFrontend Clear concepts. Working examples.