Modules: import & export
Named and default exports, ES modules in the browser and Node, dynamic import() and top-level await.
So far every example has lived in a single file. Real projects have hundreds of files: one for date helpers, one for the shopping cart, one for talking to the API. Modules let each file keep its own variables private and share only what it chooses to export; other files import exactly what they need. This is the standard ES module (ESM) system, built into every modern browser and Node.js.
Why modules?#
Before modules, every <script> shared one global scope. Two files that both declared function format() would silently overwrite each other, and you had to load scripts in exactly the right order. Modules fix this:
- Each module has its own scope. Top-level variables are not globals.
- Dependencies are explicit. You can see at the top of a file what it uses and where it comes from.
- Modules are always in strict mode, and each runs only once, no matter how many files import it.
Named exports#
Put export in front of any declaration you want to share:
Import them by name, inside braces:
The names must match the exports exactly. You can rename on either side with as, or import everything as a namespace object:
You can also export a list at the bottom of a file, which some people find easier to scan:
Default exports#
A module can have one default export — handy when a file is mainly about one thing, like a class or a component:
Import the default without braces, under any name you like; named exports still go in braces:
Named or default? Many teams prefer named exports: names stay consistent across the codebase, editors auto-import them reliably, and typos are caught (
import { clmap }is an error, while a misnamed default import silently works). Default exports are common in React components and config files.
Running modules in Node.js#
Node supports two module systems. To use ES modules, either:
- give files the
.mjsextension, or - add
"type": "module"to yourpackage.jsonso.jsfiles are ES modules:
Then run the entry file as usual:
Two Node rules that trip people up:
- Relative imports need the file extension:
"./math.js", not"./math". (Bundlers like Vite let you omit it, but Node doesn't.) - Package imports use the bare name:
import express from "express";— Node looks innode_modules. Built-in modules use thenode:prefix:import { readFile } from "node:fs/promises";.
CommonJS: the older Node format
You'll still see this in older Node code and many tutorials:
New code should use import/export. Modern Node can import CommonJS packages, so you can use both kinds of library from an ES module.
Modules in the browser#
Add type="module" to the script tag:
Module scripts behave differently from classic scripts:
- They are deferred automatically: they run after the HTML is parsed, so the DOM is ready.
- Top-level variables are not added to
window. - They're loaded with CORS rules, so opening the page with
file://fails. Serve the folder with a local server, e.g.npx serve .or the VS Code Live Server extension. Build tools like Vite do this for you. - Browser imports need a full path including the extension (
"./utils.js"), unless a bundler or an import map resolves bare names.
Re-exporting: "barrel" files#
A folder can expose a single entry point that gathers exports from several files. Say math.js, strings.js and Cart.js live in a utils/ folder:
Barrels are convenient, but huge ones can slow down tooling. Use them in moderation.
Imports are live, read-only bindings#
An import is not a copy; it's a live view of the exported variable. The importing module can't reassign it:
And because a module runs only once, every importer shares the same count. That makes a module a natural place for shared state, like a single config object or API client.
Dynamic import()#
Static import statements must be at the top level and are loaded before your code runs. Dynamic import() loads a module on demand and returns a promise:
In the browser this is how you lazy-load heavy code — a chart library, an editor, a page of an app — only when the user needs it. Build tools split dynamically imported modules into separate files automatically.
Top-level await#
Inside ES modules you can use await at the top level, outside any async function (you'll learn await properly in the async module):
Any module importing config.js waits until it has finished loading. Use this for one-time setup; avoid slow top-level awaits, since they delay everything that depends on the module.
import.meta.url is the URL of the current module file — useful for loading files relative to it. In Node 20.11+ you also get import.meta.dirname and import.meta.filename.
Common mistakes#
- Forgetting the extension in Node or the browser:
import { x } from "./utils"→ Cannot find module. - Mixing up default and named imports:
import { Cart } from "./Cart.js"whenCartis the default export → does not provide an export named 'Cart'. - Using
importin a file Node treats as CommonJS (a.cjsfile, or a project with"type": "commonjs") → Cannot use import statement outside a module. Add"type": "module"or use.mjs. (Recent Node versions also auto-detectimport/exportin plain.jsfiles, but being explicit avoids surprises and a small startup cost.) - Opening a module page via
file://and wondering why it's blank — check the console for a CORS error and use a local server. - Circular imports (A imports B, B imports A) can give you
undefinedor a Cannot access before initialization error. Move the shared code into a third module.
What's next#
Modules organise your code; now let's organise your data. Next up: Map, Set, WeakMap and WeakSet — collections that go beyond plain objects and arrays.
Check your understanding
Quick quiz
1.A file has
export default function greet() {}andexport const VERSION = 2;. Which import gets both?2.How do you tell Node.js to treat
.jsfiles in a project as ES modules?3.What does
await import("./charts.js")do?
Finished reading?
Mark this lesson complete to track your progress.