Modules, Imports, and Exports in JavaScript

Published Updated

JavaScript modules split a program into files that share selected functions and values through import and export. For new code, I prefer named exports and reserve a default export for a file that does one thing. You need Node installed to run the first example.

A module has its own scope, so its variables do not automatically become globals. If functions and scope are new to you, start with JavaScript language basics.

How Do Named and Default Exports Differ?

Named exports share values under specific names; a default export supplies one value that the importing file can name freely. A module can have several named exports and one default export. Here is a small example. 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)));

Declare the module format explicitly by adding 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 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.

How Do JavaScript Import Paths Work?

JavaScript resolves an import path according to its environment. Node requires file extensions on relative ES module paths, while browsers request the specified URL. Dropping the extension from this Node example causes an error:

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 can resolve extensionless paths according to their configuration. 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.

How Do ES Modules Differ From CommonJS?

ES modules use import and export; CommonJS uses require and module.exports. CommonJS remains common in existing Node projects:

// 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, but browser code must use browser-compatible dependencies. A bundler can resolve package names; it cannot make Node filesystem or other server-only APIs available in a browser.

How Do You Use JavaScript Modules in a Browser?

Load a JavaScript module in a browser with type="module" on the script tag, and serve the files through a web server:

<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, without the async attribute, 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.

When Do JavaScript Modules Run?

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

A static import must also sit at the top level of a module. Putting it inside a function or block causes a syntax error, whose wording varies by engine. Use import() when loading a module conditionally or inside a function. It returns a promise that resolves to the module’s exports; the promises and async/await guide explains how to use the result.

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 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 lookup that fails. A package that blocks a subpath through its exports field usually produces ERR_PACKAGE_PATH_NOT_EXPORTED instead.

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 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 JavaScript modules use named or default exports?

I prefer named exports in JavaScript modules because each import explicitly requests an exported name. Use a default export when a file has one main purpose, such as a single component. Both forms support local renaming, and both fail during linking if the requested export is missing.

Why does a JavaScript import need a .js extension?

Node’s native ES module loader requires a file extension on relative file imports. A browser instead requests the specified URL, which can omit an extension if the server returns JavaScript there. Bundlers may resolve extensionless paths, depending on their configuration.

Do JavaScript modules need a build tool?

JavaScript modules do not need a build tool. Modern browsers support import and export through a script with type="module", and Node runs ES modules natively. Bundlers can still optimize larger projects and resolve package imports.

Can a JavaScript project mix ES modules and CommonJS?

A Node project can mix ES modules and CommonJS. ES modules can import CommonJS, and current Node releases can require synchronous ES modules whose dependencies do not use top-level await. Browser projects need compatible tooling to convert CommonJS before loading it.

Sources

  1. [1]
    JavaScript Modules
    (developer.mozilla.org)
  2. [2]
  3. [3]
    Modules: Packages
    (nodejs.org)