JavaScript Fundamentals: ES Modules: import and export
ES Modules let you split your JavaScript code across multiple files. Instead of dumping everything into one massive file, you can create small, focused modules that do one thing well and export the pieces other files need. This JavaScript fundamental makes your code easier to organize, test, and maintain. In this tutorial, you’ll learn how to export from a module and import it elsewhere.
Why use modules?
Before ES Modules arrived in 2015, JavaScript had no built-in way to share code between files. Developers relied on workarounds like including multiple script tags in HTML, which created global variable pollution and made dependency management messy.
ES Modules solve these problems by giving each file its own scope. Variables and functions you declare in a module don’t accidentally leak into other files. You explicitly choose what to expose through exports, and other files explicitly request what they need through imports.
This approach also enables better tooling. Bundlers like webpack can analyze your imports and exports to remove unused code (tree-shaking), and browsers can load modules asynchronously for better performance.
The export Statement
You can export values from a module in two ways: named exports and a default export. A module can have multiple named exports but only one default export.
Named Exports
Named exports let you expose specific values from your module. You can export declarations directly, or export names that were declared elsewhere in the file. The first form is more common for small modules where the export and the definition live side by side:
// math.js - Export declarations directly
export const PI = 3.14159;
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
export class Calculator {
constructor(initialValue = 0) {
this.value = initialValue;
}
add(n) {
this.value += n;
return this.value;
}
}
You can also export a list of names declared elsewhere. This style is useful when the module defines several functions or constants and you want to decide at the bottom of the file which ones are public. It creates a clean separation between implementation and the module’s public API:
// utils.js
const formatDate = (date) => {
return date.toISOString().split('T')[0];
};
const capitalize = (str) => {
return str.charAt(0).toUpperCase() + str.slice(1);
};
// Export both at once
export { formatDate, capitalize };
You can rename exports to avoid conflicts or create cleaner public APIs. When the internal name of a function is verbose or implementation-specific, exporting it under a shorter or more descriptive name gives consumers a better experience without changing the code that calls it internally:
// Renaming on export
export { formatDate as formatIsoDate, capitalize as titleCase };
Default Export
A module can have one default export. Unlike named exports, the default export does not have a fixed name, so the importing file chooses what to call it. This makes default exports a natural fit for modules that expose a single main function or class—think of a logger, a database client, or a React component:
// logger.js - Default export
const log = (message) => {
console.log(`[LOG] ${new Date().toISOString()}: ${message}`);
};
export default log;
// You can also write it inline:
export default function(message) {
console.log(`[LOG] ${new Date().toISOString()}: ${message}`);
}
The difference matters when importing. Named exports must be imported with their exact names using curly braces, or aliased with as. Default exports can be renamed to anything the importing file chooses. This distinction shapes how you design your module’s API: if callers need a predictable, stable name, use named exports. If the module is best described by a single function, default export is cleaner.
The import Statement
Importing lets you bring exported values into another module. There are several import forms to match different export patterns.
Named Imports
Use curly braces to import specific named exports. The names inside the braces must match the exported names exactly—this is why named exports encourage consistent naming across your codebase. If two modules export a function with the same name, you will need to alias at least one of them:
// main.js
import { add, subtract, PI } from './math.js';
console.log(add(5, 3)); // 8
console.log(subtract(10, 4)); // 6
console.log(PI); // 3.14159
You can rename imports to avoid conflicts. This is essential when two dependencies export the same name—for example, a date formatting library and a logging library might both export a format function. Aliasing lets you keep both without ambiguity:
import { add as sum, subtract as minus } from './math.js';
console.log(sum(2, 3)); // 5
console.log(minus(10, 3)); // 7
Default Import
Default imports skip the curly braces entirely. The import name is entirely up to you; the exporting module imposes no constraint. This makes default imports feel more like variable declarations than structural matching, which is why they read naturally in code that uses them heavily:
import log from './logger.js';
log('Application started'); // [LOG] 2026-03-07T04:00:00.000Z: Application started
Since default exports don’t have a fixed name, you can call the import whatever you want. This flexibility is the key difference from named exports: the importing module decides the binding name, not the exporting module. Use this when the module has a single main purpose, like a logger or a configuration object:
import myLogger from './logger.js';
import whatever from './logger.js';
// Both work - you're naming it yourself
Namespace Import
Import everything as a single object. This is convenient when you need most exports from a module and don’t want to list them individually. The namespace object is a frozen module record, not a plain object, so you cannot add or remove properties from it:
import * as MathUtils from './math.js';
console.log(MathUtils.add(2, 3)); // 5
console.log(MathUtils.PI); // 3.14159
console.log(MathUtils.Calculator); // [class Calculator]
The namespace object contains all exports as properties. The default export is available as the default property. This is a subtle detail that trips up developers who expect import * as mod to give them the default export directly:
import * as logger from './logger.js';
logger.default('Hello'); // Works - default export is on .default
Combining Imports
You can mix default and named imports in one statement. This is the most common pattern in real codebases: grab the main function as the default import and any supporting utilities as named imports, all from one line. Mixing both styles in a single import statement keeps your import section compact while still giving you the flexibility of named bindings:
import log, { formatDate, capitalize } from './utils.js';
log(capitalize(formatDate(new Date())));
Using modules in the browser
Everything we have covered so far applies to any ES module environment—Node.js, Deno, or the browser. In the browser, you need one extra step: telling the HTML parser that a script should be treated as a module. Without the type="module" attribute, the browser parses your script as a classic script, and import statements will cause a syntax error:
To use ES Modules in a browser, add type="module" to your script tag:
<!-- index.html -->
<script type="module" src="main.js"></script>
<!-- You can also write inline modules -->
<script type="module">
import { add } from './math.js';
console.log(add(2, 3)); // 5
</script>
When you use modules in the browser, you need to run them through a local web server. Opening the HTML file directly in the browser (file:// protocol) won’t work due to CORS restrictions. If you’re using VS Code, the Live Server extension handles this nicely.
# Using Python's built-in server
python -m http.server 8000
# Or Node's http-server
npx http-server -p 8000
File extensions
You might see modules with .mjs extension instead of .js. The .mjs extension explicitly signals to Node.js and build tools that the file should be parsed as an ES module, regardless of the "type" field in package.json. Browsers treat both .js and .mjs as valid module targets:
// This works
import { add } from './math.js';
// So does this
import { add } from './math.mjs';
For a beginner, sticking with .js is fine, but know that .mjs exists if you encounter it.
Common patterns and gotchas
Importing from Packages
When importing from npm packages, use the package name directly. The bundler or runtime resolves the package name to the correct file using the node_modules directory and the package’s main or module field. You never need to know the internal file structure of your dependencies:
import { format } from 'date-fns';
import chalk from 'chalk';
console.log(format(new Date(), 'yyyy-MM-dd'));
console.log(chalk.green('Success!'));
Modules are singletons
A module is executed only once, regardless of how many times it is imported. Every importer gets a reference to the same set of exported values. This singleton behavior is what makes modules useful for sharing state across your application—a counter module, for instance, will have the same count value everywhere it is imported:
// counter.js
export let count = 0;
export function increment() {
count++;
}
// main.js
import { count, increment } from './counter.js';
import { count as count2, increment as inc2 } from './counter.js';
increment();
console.log(count); // 1
console.log(count2); // 1 - same value!
Live Bindings
Exported values are live bindings to the original. If the exporting module changes a value, importers see the change. This is fundamentally different from copying a value at import time—the binding stays connected to the source module for the lifetime of the program. This behavior is what makes module-level state sharing work reliably across files:
// state.js
export let value = 'initial';
setTimeout(() => {
value = 'changed';
}, 1000);
// main.js
import { value } from './state.js';
console.log(value); // "initial"
setTimeout(() => {
console.log(value); // "changed" - updated from the other module!
}, 2000);
Re-exporting
You can re-export values from other modules, which is useful for creating “barrel” files that aggregate exports. A barrel file collects exports from several modules into a single entry point. Consumers import from one file instead of navigating the internal directory structure, and you can reorganize the underlying modules without updating every import site:
// index.js - Barrel file
export { add, subtract } from './math.js';
export { formatDate, capitalize } from './utils.js';
export { default as log } from './logger.js';
// Now consumers can import from one file
import { add, formatDate, log } from './index.js';
Export shape matters
The way you export values affects how easy a module is to read and use. Named exports work well when a file contains several related helpers, while a default export fits a single main value or function. Mixing styles in the same project can be confusing, so pick a pattern that matches the module’s job. A clean export shape helps callers know what to import without opening the file first. That small bit of consistency pays off in larger codebases where modules get moved around often.
Manage cycles deliberately
Circular imports can be useful, but they need care. If module A depends on module B and module B depends on module A, the order of evaluation matters and partially initialized values can appear. The safest approach is to move shared pieces into a third module or to keep the cycle shallow and well understood. When a cycle is intentional, document why it exists. Future readers should know whether it is a stable design or a temporary bridge that should be removed later.
Match the loader to the environment
The same import statement feels simple, but the runtime behind it changes the details. Browsers need URLs or import maps, Node.js cares about file extensions and package type, and bundlers may rewrite paths entirely. When you switch between environments, check how each one resolves modules and what it does with dynamic import(). Understanding that loader behavior prevents a lot of “works here, fails there” debugging later. It also helps you choose the lightest tool that still fits the project.
Summary
ES Modules give JavaScript a standard way to organize code across files. You export what you want to share using export, and import it elsewhere with import. Named exports work well for multiple related values, while default exports are perfect for a module’s main functionality.
In the browser, use type="module" on your script tags and serve files through a local web server. Remember that modules are singletons with live bindings, which affects how you think about shared state.
These fundamentals prepare you for working with npm packages, modern frameworks like React and Vue, and build tools like webpack and Vite. Your next step is to try building a small project with multiple module files to solidify these concepts.
Next steps
- Try converting a single-file script into multiple ES modules—separate concerns like data fetching, UI rendering, and utility functions into their own files
- Experiment with dynamic
import()to lazy-load a module only when a user triggers a specific action - Read MDN’s ES Modules guide for deeper coverage of module loading and browser compatibility