ES Modules in JavaScript: Import, Export, and Design
JavaScript modules let you break your code into reusable pieces. Instead of putting everything in one file, you can split related code into separate files and import what you need. This makes code easier to maintain, test, and understand.
When you first start writing JavaScript, everything goes in a single file. That works fine for small scripts. As your project grows, you find yourself scrolling through thousands of lines looking for one function. Modules solve this by letting you organize code into logical files.
The import and export Syntax
ES Modules use import and export keywords. You export things from a file to make them available to other files, then import them where needed.
// math.js - the exporting file
export function add(a, b) {
return a + b;
}
export const PI = 3.14159;
The export keyword makes values visible to other modules; everything unexported stays private inside the file. When you import, the module path tells the runtime which file to load, and the curly braces pick out specific bindings. Unlike a single default, a module can export as many named values as it needs.
// main.js - the importing file
import { add, PI } from './math.js';
console.log(add(2, 3)); // 5
console.log(PI); // 3.14159
The curly braces here are called “named imports”; they pull out specific exports by their name. Named imports also support the as keyword for local renaming, which helps when two modules export the same symbol.
Default Exports
Each module can have one default export. Default exports don’t need curly braces when importing, and the importer picks the local name freely. This makes default exports a natural fit for a module’s main purpose: a single function, class, or object that the file is built around.
// logger.js
export default function log(message) {
console.log(`[LOG] ${message}`);
}
Here log is the default export of logger.js. The importing file gets to choose whatever local name makes the most sense in its context; the module author does not force the consumer into a particular naming convention. That flexibility is one reason default exports work so well for library entry points.
// main.js
import log from './logger.js';
log('Hello'); // [LOG] Hello
Because default imports use no curly braces, the named-import and default-import syntaxes are easy to tell apart at a glance. You can rename the imported binding to something that fits your module’s naming style. The shape of the import statement signals the type immediately, which makes scanning imports faster in larger files:
import myLogger from './logger.js';
A module that exports both a default and named values is common in library code. The default is the primary entry point, while named exports expose secondary utilities that only some consumers need. Lodash and React are well-known examples of this dual-export pattern:
// utils.js
export default function helper() { }
export function format() { }
The import site combines both syntaxes: the default import comes first, followed by named imports in curly braces. The ECMAScript specification actually requires the default binding to appear before any named bindings in the same import declaration. The parser enforces this ordering at parse time:
import helper, { format } from './utils.js';
The order matters: the default import always appears before any named imports in the same statement.
Re-exporting
You can re-export values from one module to another without importing and using them first. This pattern lets you create aggregation modules that collect exports from several files and present them as a single entry point. Re-exporting is purely a pass-through—the intermediary never touches the imported values.
export { add } from './math.js';
export { default } from './logger.js';
A common use for re-exporting is a barrel file: a module that imports from many sources and re-exports everything under one name. This way, consumers import from the barrel instead of knowing the internal directory layout. Barrel files are especially useful in monorepos and large libraries where internal structure changes often:
// index.js - barrel file
export { add, subtract } from './math.js';
export { format } from './string.js';
export { log } from './logger.js';
The consumer sees a single clean import path, regardless of how the internal module tree is structured. As the codebase grows, you can reorganise files without updating every import statement across the project. This indirection costs nothing at import time and pays off every time the directory layout shifts:
import { add, format, log } from './index.js';
Dynamic Imports
Barrel files work well when you know every export you need at build time. Sometimes, though, a module is only needed in a specific scenario. After a user clicks a button, navigates to a route, or triggers a feature that most sessions never touch, you want to defer the download until it is actually required.
Dynamic import() returns a promise:
button.addEventListener('click', async () => {
const { format } = await import('./string.js');
format('hello');
});
This is useful for code splitting—loading heavy modules only when needed. A browser will only download the module when the user clicks the button, not when the page loads.
The type=“module” Script Attribute
Browsers need to know when to treat a script as a module:
<script type="module" src="main.js"></script>
Module scripts are deferred automatically, meaning they don’t block HTML parsing. The browser downloads main.js and continues parsing the document at the same time—execution waits until the DOM is ready, similar to the old defer attribute. This is the default behaviour for any script loaded with type="module".
You can also write modules directly in HTML with an inline script tag. This is useful for small page-specific logic that would be overkill to put in its own file:
<script type="module">
import { add } from './math.js';
console.log(add(1, 2));
</script>
Common Pitfalls
Modules are scoped differently than regular scripts. Variables declared in a module never leak to the global scope, which prevents naming collisions between files. This scoping is one of the strongest reasons to adopt modules even in small projects:
// script1.js
var count = 0; // this stays in this module
// script2.js
var count = 0; // this is a different variable, not the same one
CORS blocks module scripts loaded from different origins unless the server sends proper Access-Control-Allow-Origin headers. This means you cannot simply import from a CDN or another domain unless that domain explicitly allows cross-origin access. In practice, this pushes developers toward bundlers or same-origin hosting:
// This fails without CORS headers
import { something } from 'https://other-domain.com/module.js';
The importing file needs the .js extension when working with local files in most bundlers. Node.js can sometimes infer it, but browsers need the exact path.
Designing module boundaries
Modules work best when each file has a clear purpose. A file that exports a focused set of functions is easier to scan than one that collects unrelated helpers. This is not just about style. Clear boundaries make refactors safer because you can move one piece without wondering which other parts depend on it. When a module name matches the job it does, import statements become easier to understand and the project structure feels more intentional.
It also helps to think about public and private code inside a module. The export list is the public surface, while everything else stays internal. That makes it easier to change implementation details without forcing every importer to change too. A small export surface also encourages better naming because the values you expose need to make sense on their own. If a module ends up exporting too much, it is often a sign that the file wants to split into a few smaller pieces.
Common project patterns
ES Modules are a good fit for shared utilities, configuration files, and feature-specific helpers. They also work well for shared constants and one-time setup. A module can expose a function, a constant, or a default object, but the most maintainable modules usually keep the API small. That gives the rest of the app a stable point of contact without turning the module into a dumping ground for everything that feels related.
When you need conditional loading, dynamic imports can keep rarely used code out of the initial path. That is useful for features like admin panels, editors, or heavy visual tools that should not load until the user opens them. The result is a smaller starting bundle and a cleaner split between core behavior and optional behavior. In larger apps, that split can make the first interaction feel much faster.
Keeping imports clean
Import style matters because it shapes how people read the file. Group related imports together, keep names consistent, and avoid re-exporting so many things that the source of truth becomes hard to find. Good module code feels like a map: each import tells you where a value comes from, and each export tells you what that file owns. That kind of clarity pays off when the codebase grows and new contributors need to find the right file quickly.
It also helps to treat modules as a boundary for dependency direction. A higher-level feature should import lower-level utilities, not the other way around. That keeps the architecture from becoming tangled and makes it easier to test pieces in isolation. When the dependency graph stays simple, modules are much more than syntax. They become the shape of the system itself.
A final module rule
When a file starts to feel like a grab bag, it is usually time to split it. Small, focused modules are easier to test, easier to import, and easier to move around as the project grows. That does not mean every file has to be tiny. It means the exported surface should match one clear responsibility so the rest of the code knows what to expect.
The import graph is part of the design too. If dependencies keep pointing in one clear direction, the project is easier to understand and safer to change. When a module boundary feels natural, the code around it usually becomes calmer as well.
A tidy module also makes it easier to spot dead code, because each export has a clear reason to exist.
That makes refactors calmer too, because you can move one module at a time without guessing what the rest of the app expects from it.
It also keeps the public surface easy to audit when the project grows.
That is a small benefit, but it adds up across a large codebase.
The result is less guesswork every time you open the file again.
That helps every later change feel simpler.
See Also
Array.isArray()— Check if a value is an arrayPromise— Handle asynchronous operations- Module federation — Share ES modules across separate builds at runtime
- ES Modules documentation on MDN