Skip to content
JavaScript js modules 4 min read

import & export

ES modules let you split a program across files and share only what you choose. The export keyword marks values that other files may use, and import pulls those values into the current module by name. This static, declarative syntax is the foundation of modern JavaScript packaging — understood natively by browsers, Node.js, and every bundler. Mastering the export/import grammar means you can structure large codebases cleanly and predictably.

Named exports and imports

A named export exposes a binding under a specific identifier. A module can have any number of named exports, and consumers import them by their exact names inside curly braces.

// math.js
export const PI = 3.14159;

export function area(radius) {
  return PI * radius ** 2;
}

export class Circle {
  constructor(r) {
    this.r = r;
  }
}

You can also declare values first and export them together in one statement, which many teams prefer because the public surface of the file is listed in a single place:

// math.js
const PI = 3.14159;
function area(radius) {
  return PI * radius ** 2;
}

export { PI, area };

Import them by matching the names exactly:

// app.js
import { PI, area } from './math.js';

console.log(area(2));

Output:

12.56636

The .js extension is required in the browser and in Node.js when using native ESM. Bundlers like Vite often let you omit it, but writing the full path keeps your code portable.

Default export and import

A module may have at most one default export — the single “main” thing it provides. The importer chooses any name for it, with no braces.

// logger.js
export default function log(message) {
  console.log(`[LOG] ${message}`);
}
// app.js
import log from './logger.js';
import writeLog from './logger.js'; // same value, different local name

log('Server started');

A file can mix one default export with several named exports:

// user.js
export default class User { /* ... */ }
export const ROLES = ['admin', 'editor', 'viewer'];
import User, { ROLES } from './user.js';

Renaming with as

The as keyword renames a binding on either side of the boundary. Use it on export to publish a different public name, or on import to avoid a collision in the consuming file.

// shapes.js
function area(r) { return 3.14159 * r * r; }
export { area as circleArea };
// app.js
import { circleArea as a } from './shapes.js';
console.log(a(3));

Namespace import (import *)

To grab every named export at once, import the whole module as a namespace object. Each export becomes a property on that object. (A default export, if present, appears as the .default property.)

// app.js
import * as math from './math.js';

console.log(math.PI);
console.log(math.area(5));

Output:

3.14159
78.53975

Re-exporting

A barrel module can forward exports from other files, giving consumers one tidy entry point. The export ... from syntax re-exports without first importing into the local scope.

// index.js — barrel
export { area, PI } from './math.js';
export { default as User } from './user.js'; // re-export a default as named
export * from './shapes.js';                 // forward all named exports
// app.js
import { area, User, circleArea } from './index.js';

Live, read-only bindings

Imports are not copies — they are live read-only bindings to the original variable. When the exporting module updates an exported let, importers immediately see the new value. You cannot reassign an import, though; doing so throws a TypeError.

// 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  ← reflects the live update

// count = 5; // ❌ TypeError: Assignment to constant variable

Output:

0
1

Hoisting and static structure

Import declarations are hoisted to the top of the module and resolved before any code runs, so an import can appear physically below code that uses it. Because the structure is static, the bindings exist throughout the module.

console.log(greet('Ada')); // works — import is hoisted

import { greet } from './greet.js';

This static analysis is what enables tree-shaking (dead-code elimination) in bundlers. It also means import and export must live at the top level of a module — never inside an if, a function, or a loop. For conditional or on-demand loading, use the dynamic import() function instead.

FormPer fileBraces?Importer names it
Named exportmanyyesmust match (or as)
Default exportonenofreely chosen
Namespace * asn/anofreely chosen

Best Practices

  • Prefer named exports for libraries with multiple utilities — they support tree-shaking and make refactors with editor tooling safer.
  • Reserve default export for a module’s single primary value (a component, a class, a main function).
  • Always include the file extension in relative specifiers for native ESM compatibility.
  • Keep import/export statements at the top level; reach for import() only when you genuinely need lazy loading.
  • Use barrel files to define a clean public API, but watch for over-large barrels that defeat tree-shaking.
  • Treat imports as read-only: never attempt to reassign them, and avoid mutating imported objects unless that is the module’s intended contract.
Last updated June 1, 2026
Was this helpful?