Updated June 2026. Tested on modern JavaScript (ES2020+). Part of the Techalyst JavaScript series.

Before ES modules, splitting JavaScript across files meant leaky globals or awkward IIFE tricks. ES modules gave the language a real system: every file is its own scope, and you say explicitly what leaves (export) and what comes in (import). It is the foundation every modern bundler and framework builds on. Here is the practical set.

Named exports

A named export attaches a specific name to a value. A file can have as many as it likes, and the importer must use the same name (or rename it).

// math.js
export const PI = 3.14159
export function add(a, b) {
  return a + b
}
// app.js
import { PI, add } from './math.js'

The braces are required for named imports, and they are not destructuring, they are import syntax that happens to look similar. You can also collect exports at the bottom of a file (export { PI, add }) if you prefer one place that lists everything a module exposes. Rename on either side with as:

import { add as sum } from './math.js'

Default exports

A default export marks the one main thing a module provides. The importer picks any name, with no braces.

// Logger.js
export default class Logger { /* ... */ }
import Logger from './Logger.js' // any name works

A file can have at most one default export. Use a default for single-concept files (a class, a config object, a Vue component) and named exports for modules that ship several related things. A module can mix both, the default comes first, before the braces:

import mainHelper, { sideHelper, VERSION } from './utils.js'

Many style guides lean toward named exports everywhere, because they give typo protection (a wrong name is a build error) and refactor cleanly. It is a reasonable default.

Namespace imports and barrels

Pull everything a module exports into one object with * as:

import * as math from './math.js'
math.add(2, 3)

A related pattern is the barrel: an index.js that re-exports from several files so consumers import from one path instead of knowing the folder layout.

// features/auth/index.js
export { default as LoginForm } from './LoginForm.vue'
export { useAuth } from './useAuth.js'
import { LoginForm, useAuth } from './features/auth'

Barrels are convenient but can hurt tree-shaking if they re-export heavy modules wholesale, so keep them intentional.

Dynamic import for code splitting

The import() function (note the parentheses) loads a module at runtime and returns a promise. This is what powers code splitting and lazy loading, the module is only fetched when that line runs.

async function openEditor() {
  const { Editor } = await import('./Editor.js') // own chunk, loaded on demand
  return new Editor()
}

Unlike the static import statement, a dynamic import can take a computed path and creates a separate chunk. It is exactly the mechanism behind lazy-loaded Vue routes, where component: () => import('./views/Page.vue') keeps each page out of the initial bundle.

Live bindings: imports are references, not copies

This is the subtle part that trips people up, and the key difference from CommonJS require. A named import is a live, read-only reference to the original variable, not a snapshot. If the source module updates the variable, your import sees the new value.

// counter.js
export let count = 0
export function increment() { count++ }
// app.js
import { count, increment } from './counter.js'
console.log(count) // 0
increment()
console.log(count) // 1, the binding updated

You cannot reassign an import (count = 5 throws), only the owning module can change it. This is why a module that exports state behaves like a shared singleton: a module's body runs once, and every importer shares that one instance.

Running modules in the browser

Browsers support modules natively with type="module" on the script tag, which also turns on strict mode, gives the script its own scope, and defers loading.

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

Two browser-specific rules: paths need the file extension (./math.js, not ./math), and bare specifiers like import 'lodash' do not resolve without an import map. Bundlers like Vite handle both for you, which is the usual reason you never think about them.

Wrapping up

Use named exports for modules with several values (and the typo protection and tree-shaking they bring), default exports for single-concept files. Reach for namespace imports and barrels to tidy up consumption, and import() for code splitting. Remember that named imports are live bindings and modules run once, which is what makes module-level state act as a singleton. In the browser you need type="module", but most of the time a bundler smooths the rough edges.

More in the series: JavaScript promises and async/await and closures and the factory pattern. Questions welcome below.