. In Node, either use the .mjs extension or set \"type\": \"module\" in package.json."}},{"@type":"Question","name":"What's the difference between default and named exports?","acceptedAnswer":{"@type":"Answer","text":"A module can have many named exports (export function foo() {}) but only one default export (export default ...). Named exports have to be imported with the exact same name in braces: import { foo } from './x.js'. A default export can be imported under any name: import whatever from './x.js'."}},{"@type":"Question","name":"What is dynamic import() in JavaScript?","acceptedAnswer":{"@type":"Answer","text":"import() called as a function returns a promise that resolves to the module's exports. Unlike the static import statement, it runs at call time, so you can load code conditionally or on demand. It's how you implement code-splitting and lazy loading."}},{"@type":"Question","name":"Do I need a file extension in import paths?","acceptedAnswer":{"@type":"Answer","text":"In native ES modules — browsers and Node's ESM loader — yes. You have to write ./utils.js, not ./utils. Bundlers like Vite and webpack are more forgiving and will resolve extensionless paths for you, but relying on that makes your code non-portable."}}]}
Menu

JavaScript ES Modules: import, export, and Dynamic Loading

How ES modules work in JavaScript — named and default exports, import syntax, dynamic import(), and the rules that separate modules from regular scripts.

A Module Is a File With Its Own Scope

Before ES modules existed, every <script> tag dumped its variables into the global namespace, and load order decided what saw what. ES modules fix that by making each file its own scope. Nothing leaks out unless you explicitly export it. Nothing comes in unless you explicitly import it.

Two files, one exporting, one importing:

index.js
Output
Click Run to see the output here.

add and multiply live in math.js. They only become visible in main.js because of the import. Nothing else in math.js — helpers, constants, whatever — is reachable from the outside.

Two rules follow from this and are worth internalizing early:

  • Modules run in strict mode automatically. No 'use strict' needed.
  • Top-level this is undefined, not the global object.

Named Exports: Export As You Go

The most common form. Stick export in front of any function, class, const, or let and it becomes part of the module's public surface:

index.js
Output
Click Run to see the output here.

The names in the braces have to match the exported names exactly — import { circlearea } would fail. If a name clashes with something you already have, rename on import with as:

index.js
Output
Click Run to see the output here.

You can also list exports at the bottom of a file instead of inline, which some people prefer for a clear "public API" section:

index.js
Output
Click Run to see the output here.

Both styles produce the same result. Pick one and stay consistent within a project.

Default Exports: One Per Module

A module can also have a single default export. Defaults are for files that really have one main thing — a component, a class, a config object:

index.js
Output
Click Run to see the output here.

Three things to notice:

  • No braces around log on the import side.
  • The imported name is whatever you want. import shout from './logger.js' would work identically.
  • Only one default export per file. Try to add a second and the file won't parse.

Named and default exports can coexist:

index.js
Output
Click Run to see the output here.

Default first, then named in braces. The order is fixed.

Which should you use? Named exports are easier to refactor — renaming across a codebase is a single find-and-replace because every import uses the same name. Defaults are flexible but let every caller pick a different name, which makes grep harder. Most modern style guides lean toward named exports and reserve default for genuinely single-purpose modules.

Import Everything, Re-Export, Side Effects

A few more import shapes you'll run into.

Grab every named export into one namespace object:

index.js
Output
Click Run to see the output here.

Re-export from another module without importing into the current scope:

index.js
Output
Click Run to see the output here.

That pattern is how library entry points gather pieces from internal files into one public surface.

And finally, an import with no bindings at all — for modules whose only job is side effects (polyfills, CSS-in-JS, registering handlers):

index.js
Output
Click Run to see the output here.

The file runs once; nothing is imported by name.

Imports Are Static and Live

Two properties of import that occasionally surprise people.

Static. import declarations are resolved before any of your code runs. You can't put one inside an if, a function, or a try. The path has to be a string literal, not a variable. That's what lets tools analyze imports without executing the code — bundlers, type checkers, tree-shakers all rely on it.

// Not allowed — SyntaxError.
if (userWantsFancy) {
  import { fancy } from './fancy.js';
}

If you need conditional loading, use import() (coming up next).

Live. An imported binding is a read-only reference back to the export, not a snapshot. If the exporting module reassigns the value, importers see the new value:

index.js
Output
Click Run to see the output here.

You also can't reassign an import on the consumer side — count = 5 in main.js would throw. Imports are read-only views.

Dynamic import() for Loading on Demand

When you need to decide at runtime whether to load a module — heavy features, route-based code splitting, conditional polyfills — use import() as a function. It returns a promise that resolves to the module's exports:

index.js
Output
Click Run to see the output here.

Because it's a regular function call, you can:

  • Await it inside an async function.
  • Pass a variable as the path.
  • Use it inside an if or try/catch.

Destructuring the resolved object works the same as a static import:

index.js
Output
Click Run to see the output here.

default is the key for the default export when you destructure. Rename it to whatever you want.

The practical use cases are code-splitting (only ship the chart library when the user clicks "show chart"), feature-detection polyfills, and plugins that are discovered at runtime.

Running ES Modules: Browsers and Node

The syntax is the same everywhere; the thing that changes is how the runtime finds and loads the file.

In the browser, mark the entry script as a module:

<script type="module" src="./main.js"></script>

With type="module", the browser respects import/export, runs the code in strict mode, and defers execution until the HTML is parsed. Paths must be relative (./, ../) or absolute URLs — bare specifiers like import 'lodash' don't work without an import map or a bundler.

In Node, there are two ways to opt in:

  • Name the file .mjs, or
  • Set "type": "module" in the nearest package.json, which makes every .js file a module.

Node also requires full paths with extensions: import './utils.js', not import './utils'.

// package.json
{
  "type": "module",
  "main": "./index.js"
}

Both environments require explicit extensions in native ESM. Bundlers (Vite, webpack, esbuild) will resolve extensionless paths for you during development — convenient, but relying on it means your source won't run without the build step.

Common Pitfalls

A few things that trip people up:

  • Forgetting type="module" in the browser. Without it, <script> runs as a classic script and import is a syntax error.
  • Missing file extensions in Node. import './utils' fails; import './utils.js' works. Bundlers hide this, native runtimes don't.
  • Expecting __dirname or require in an ES module. Those are CommonJS-only. In ESM, use import.meta.url and convert it when you need a path.
  • Circular imports that touch values before they're ready. Two modules importing each other is legal, but reading an export that hasn't been assigned yet gives you undefined. Structure your code so the cycle isn't hit during initialization, or break it up.
  • Trying to conditionally import. The static import statement doesn't allow it. Use dynamic import() for anything runtime-dependent.

Next: CommonJS vs ESM

ES modules are the standard, but plenty of Node code in the wild still uses CommonJS — require, module.exports, and a different set of rules about when code runs. Knowing both, and how they interop, is the next page.

Frequently Asked Questions

How do I use ES modules in JavaScript?

Export values from one file with export or export default, then pull them into another file with import. In the browser, load the entry file with <script type="module" src="main.js"></script>. In Node, either use the .mjs extension or set "type": "module" in package.json.

What's the difference between default and named exports?

A module can have many named exports (export function foo() {}) but only one default export (export default ...). Named exports have to be imported with the exact same name in braces: import { foo } from './x.js'. A default export can be imported under any name: import whatever from './x.js'.

What is dynamic import() in JavaScript?

import() called as a function returns a promise that resolves to the module's exports. Unlike the static import statement, it runs at call time, so you can load code conditionally or on demand. It's how you implement code-splitting and lazy loading.

Do I need a file extension in import paths?

In native ES modules — browsers and Node's ESM loader — yes. You have to write ./utils.js, not ./utils. Bundlers like Vite and webpack are more forgiving and will resolve extensionless paths for you, but relying on that makes your code non-portable.

Learn to code with Coddy

GET STARTED