Modules, Imports, and Exports in JavaScript
By the end of this page you will have a project split across two files, with one file stating exactly what it offers and the other picking up only what it needs. You need Node installed to run the examples, and nothing else.
A module is a file that keeps its own variables to itself and publishes a named list of what other files may use. Before modules, every script shared one global namespace and load order decided everything, which is where a lot of old advice about script tags comes from.
Named and Default Exports
Here is the smallest version that runs. Create a folder, add a file called prices.js, and put a named export and a default export in it:
// prices.js
export const TAX_RATE = 0.1;
export function withTax(amount) {
return amount * (1 + TAX_RATE);
}
export default function formatPrice(amount) {
return `$${amount.toFixed(2)}`;
} Then a second file that uses it. The default export is imported by whatever name you choose, and named exports are imported inside braces using their exact names:
// app.js
import formatPrice, { withTax, TAX_RATE } from "./prices.js";
console.log(TAX_RATE);
console.log(withTax(50));
console.log(formatPrice(withTax(50))); One more file is needed before Node will read these as modules. Add a package.json in the same folder containing exactly this, which tells Node to treat every .js file here as an ES module:
{ "type": "module" } Now run node app.js in a terminal opened in that folder:
// node output:
// 0.1
// 55.00000000000001
// $55.00 The middle line is floating point arithmetic, not a module problem, and the values and equality guide covers why it happens. Everything not exported, such as any helper you add to prices.js without the export keyword, stays private to that file.
The practical difference between the two forms is which mistakes are possible. A named import must request a name the module actually exports, so a typo fails to load with a message naming the missing export.
A default import's local name is yours to choose, so renaming it is never a typo. The only failure available is importing a default from a module that has none.
Both failures happen at the same moment, before any of your code runs. Native ES modules are linked first, and a request the module cannot satisfy stops the program there rather than quietly binding undefined.
Import Paths That Resolve
Change the import in app.js to drop the extension, which is how a great deal of older tutorial code is written:
import { withTax } from "./prices"; // node output:
// Error [ERR_MODULE_NOT_FOUND]: Cannot find module ... imported from ... app.js
// Did you mean to import "./prices.js"? Node's native ESM file resolution requires the extension on a relative path, and it names the fix directly in the error. A browser is looser: ./prices is a perfectly valid URL and works if the server serves JavaScript there.
Bundlers add the extension for you. That is why the same import can work in a bundled project and fail the moment you run the file yourself.
Three path shapes exist and they mean different things. A path starting with ./ or ../ is relative to the current file, and a path starting with / is absolute.
The third shape is a bare name such as "react". That names a package rather than a file, which Node looks up in node_modules, and which the browser cannot resolve without an import map or a bundler.
ES Modules Against CommonJS
Node had its own module system for years before the language got one. That system is CommonJS, and it uses require and module.exports:
// the CommonJS form, still everywhere in existing projects
const { withTax } = require("./prices.js");
module.exports = { withTax }; You will meet both, so the deciding rule is worth stating plainly. In Node, a .js file is an ES module when the nearest package.json says "type": "module", and CommonJS when it says "type": "commonjs". The .mjs and .cjs extensions force one format each regardless.
With no type field at all the file is ambiguous, and current Node inspects the source: a file containing ESM-only syntax is treated as a module. Setting type explicitly is the way to stop guessing, which is why the example above did.
Inside Node, mixing them mostly works now. An ES module can import a CommonJS file, and a CommonJS file can require an ES module: Node added that in v22.0.0 and v20.17.0, and dropped the experimental flag in v22.12.0 and v20.19.0. Browsers are a different matter, since CommonJS source needs a build step before a browser can run it at all.
The one refusal is top-level await, and it applies to the whole graph rather than the file you name. Requiring a module that imports a module that awaits fails just the same:
// node output, requiring a module that awaits at the top level:
// Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM graph
// with top-level await. Use import() instead. New code should use ES modules. They are the language standard and the tooling has settled on them. Portable module syntax runs in a browser unchanged, though a Node file that imports bare package names or node: built-ins still needs a bundler before a browser can load it.
Modules in the Browser
The browser reads the same syntax, with one attribute on the script tag:
<script type="module" src="/app.js"></script> That attribute changes four things at once. The file may use import and export, it runs in strict mode automatically, its top-level variables stay out of the global scope, and it is deferred, meaning it runs after the HTML is parsed rather than blocking it.
One rule catches everyone at least once. Module scripts are fetched over HTTP, so opening the page as a file:// URL fails with a CORS error. Serve the folder over a local server instead, which any framework dev command already does for you.
Load Order and Hoisting
Imports do not run in the order they appear among your other statements. In a straightforward graph with no cycles and no top-level await, a file's dependencies are linked and evaluated before that file's own first line:
// side.js
console.log("side effect in module");
export const value = 1;
// order.js
console.log("first line of order.js");
import { value } from "./side.js";
console.log("value is", value); // node output:
// side effect in module
// first line of order.js
// value is 1 The imported file's own output came first, even though the import statement sits below a console.log. That is import hoisting, and it is why a module with side effects runs them whether you use its exports or not.
Moving an import among ordinary statements therefore changes nothing. Reordering imports against each other can still change the order those side effects run in.
A module is also evaluated once per resolved URL, no matter how many files import it, because the result is cached. That makes a module a reasonable place for shared setup, and a poor place for anything you expect to happen fresh on each call.
The URL is what identifies it, not the file on disk. Separate workers, or the same file requested with a different query string, each get their own evaluation.
Pitfalls and Debugging
SyntaxError: Cannot use import statement outside a module. Usually Node read the file as CommonJS: add "type": "module" to the nearest package.json, or rename the file to .mjs. In the browser, the same message means the script tag is missing type="module".
The other cause is placement. A static import must sit at the top level of a module, so writing one inside a function or a block raises the same error.
ReferenceError: require is not defined. The opposite mistake, and it usually means CommonJS code was pasted into an ES module file. Convert the line to an import, or move that file to a .cjs extension if it genuinely needs the old system.
ERR_MODULE_NOT_FOUND on a relative import. A missing file extension is the common cause, and Node's message suggests the corrected path. The same error also covers a wrong path, a package that was never installed, and a package that does not expose that subpath.
An import that resolves locally and fails on the server. Usually letter case. A filename that resolves on a Mac can fail on a Linux build machine, where the filesystem is case-sensitive.
SyntaxError: The requested module does not provide an export named 'x'. A named import asking for something the file does not export, or a default import from a file with no default. Native ESM rejects this while linking rather than binding undefined. Log the whole namespace with import * as prices from "./prices.js" to see what the file actually publishes.
A circular import failing on a value that clearly exists. When two files import each other, one is still mid-evaluation when the other reads it. In ES modules that gives ReferenceError: Cannot access 'x' before initialization for a const, let, or class export, while an exported var reads as undefined.
The fix is structural. Move the shared piece into a third file that both import, rather than trying to order the imports around each other.
Frequently Asked Questions
Should you use named or default exports?
Named exports are the safer default. The requested name has to exist, so a typo is caught while the module is linked rather than surfacing later as a mystery. A local alias is still available through import { exportedName as localName }. Reserve a default export for files whose whole purpose is one thing, such as a single component.
Why does my import need a .js extension?
Native Node ESM requires it on a relative path, so ./prices resolves to nothing there. A browser is different: that specifier is a valid URL and works when the server serves JavaScript at it. Bundlers such as Vite and webpack add the extension for you, which is why the requirement looks inconsistent.
Do you need a build tool to use modules?
No. Every current browser supports import and export directly through a script tag with type="module", and Node runs them natively. A bundler still helps on larger sites by reducing the number of requests and by resolving package names, but nothing requires one to start.
Can one project mix ES modules and CommonJS?
In Node and compatible bundlers, yes, and many projects and their dependency graphs do. An ES module can import a CommonJS file, and current Node lets require load an ES module too, as long as nothing in that graph uses top-level await. Browsers cannot run CommonJS source without a build step first.
Related
- JavaScript Language Basics for the full topic overview
- Objects, Arrays, and Destructuring for the braces syntax named imports borrow from
- Functions, Callbacks, and Closures for the functions a module usually exports
- JavaScript for the language guide and the wider learning path
Sources
-
[1]
JavaScript Modules(developer.mozilla.org)
-
[2]
Modules: ECMAScript Modules(nodejs.org)
-
[3]
Modules: Packages(nodejs.org)
Read Next
Object literals and property access, the array methods worth knowing, destructuring, spread and rest, when Map and Set beat a plain object, and copying without mutating.
let and const, the primitive types, type coercion, == against ===, truthiness, the difference between null and undefined, and the ?? and ?. operators.
The four pieces of JavaScript every other page assumes: values and equality, functions and closures, objects and arrays, and modules that split code across files.