JavaScript Modules: Import and Export Explained
If you wrote JavaScript a decade ago, you probably remember the anxiety of the <script> tag stack. Building a web application meant carefully curating a list of scripts in your index.html file, hoping against hope that you got the load order right.
It looked something like this:
<script src="jquery.js"></script>
<script src="utils.js"></script>
<script src="auth.js"></script>
<script src="app.js"></script>
This approach had a fatal flaw: the global scope. Every variable, function, or class declared in those files was dumped into a single, shared global object (window in the browser). If utils.js defined a function called validate(), and auth.js also defined a function called validate(), the last script loaded won. Your application would break, and the only way to figure out why was to manually trace the execution path across multiple files.
We tried to fix this with Immediately Invoked Function Expressions (IIFEs) and revealing module patterns, but these were duct-tape solutions. We needed a primitive built into the language itself.
Enter ES Modules (ESM).
Introduced in ES6, modules completely changed how we architect JavaScript applications. Today, I want to break down exactly how they work, the difference between export strategies, and why they are the foundation of modern web maintainability—no bundlers required.
What is a Module?
At its core, a module is just a JavaScript file. But it comes with a superpower: its own isolated lexical scope.
When a file is treated as a module, the variables and functions you write inside it do not leak into the global scope. They are entirely private to that file. If another file wants to use them, the module must explicitly export them, and the other file must explicitly import them.
This explicit contract solves the script tag problem overnight. You no longer care about the global object. You declare your dependencies directly in the code that uses them.
Let's look at the two ways to build these contracts: Named Exports and Default Exports.
Named Exports
Named exports allow you to export multiple specific variables, functions, or classes from a single file. You use them when a module acts as a collection of related utilities or constants.
Exporting
Imagine we have a file called http-client.js that handles our network requests.
// http-client.js
// This is private. It stays in this file.
const BASE_URL = 'https://api.myapp.com/v1';
// We explicitly export these functions
export async function get(path) {
const response = await fetch(`\({BASE_URL}\){path}`);
return response.json();
}
export async function post(path, data) {
const response = await fetch(`\({BASE_URL}\){path}`, {
method: 'POST',
body: JSON.stringify(data),
});
return response.json();
}
export const TIMEOUT_MS = 5000;
Importing
To use these named exports in another file, we use the import statement with curly braces. The names inside the braces must match the names exported from the module.
// app.js
import { get, post } from './http-client.js';
async function fetchProfile() {
const user = await get('/users/me');
console.log(user);
}
The "Why" behind Named Exports: Named exports enforce consistency. If I export post, you have to import it as post. This makes searching your codebase incredibly easy. If I want to find everywhere the post function is used, a simple text search will yield accurate results.
If you run into a naming collision (e.g., your file already has a post function), ESM provides a clean escape hatch via aliasing using the as keyword:
import { post as httpPost } from './http-client.js';
function post(article) {
// local function
}
httpPost('/articles', article);
Default Exports
Default exports are used when a module has one primary responsibility—a single class, a single React component, or a single heavy-lifting function. A file can only have one default export.
Exporting
Let's say we have an authentication service. While it might have some internal helper functions, the only thing the rest of the app needs is the Authenticator class itself.
// auth.js
class Authenticator {
constructor() {
this.isAuthenticated = false;
}
login(token) {
this.isAuthenticated = true;
localStorage.setItem('session', token);
}
logout() {
this.isAuthenticated = false;
localStorage.removeItem('session');
}
}
// Exporting this class as the default export of this file
export default Authenticator;
Importing
When importing a default export, you omit the curly braces. Because there is only one default export per file, you can name it whatever you want in the importing file.
// app.js
import AuthService from './auth.js';
const auth = new AuthService();
auth.login('xyz-123');
Notice I called it AuthService when importing, even though it was named Authenticator in the original file.
The Tradeoff: The flexibility of naming default exports is convenient, but it can be a double-edged sword. If half your team imports it as AuthService and the other half imports it as Auth, refactoring becomes harder. As a general rule: I use default exports for UI components (like React or Vue files) where the file structure heavily dictates the architecture, and I prefer named exports for utility functions and business logic to enforce strict naming contracts.
Mixing Default and Named Exports
The real world is rarely all one or the other. You can absolutely mix default and named exports in a single file. You see this constantly in modern libraries.
// logger.js
export const LOG_LEVELS = {
INFO: 'INFO',
ERROR: 'ERROR'
};
export function setLogLevel(level) {
// ...
}
export default function log(message, level = LOG_LEVELS.INFO) {
console.log(`[\({level}] \){message}`);
}
To consume this, you combine the syntax. The default import comes first, followed by the named imports in curly braces:
// app.js
import log, { LOG_LEVELS, setLogLevel } from './logger.js';
setLogLevel(LOG_LEVELS.ERROR);
log('Database connection failed', LOG_LEVELS.ERROR);
How to Run This in the Browser
For a long time, we needed heavy bundlers like Webpack or Rollup to stitch modules together before the browser could read them. That is no longer the case. Modern browsers understand ESM natively.
To tell the browser that a script is a module (and therefore has import and export capabilities), you just add type="module" to your script tag:
<!-- index.html -->
<script type="module" src="app.js"></script>
When the browser sees type="module", it does a few things differently:
It executes the code in strict mode by default.
It automatically defers execution until the HTML is fully parsed (similar to the
deferattribute), meaning you don't have to worry about the DOM not being ready.It fetches the
app.jsfile, reads theimportstatements at the top, fetches those files, reads their imports, and maps out the entire dependency graph before executing the code.
The True Benefit is Bounding Context
We talk a lot about "reusability" when discussing modules, but in my experience, that’s actually a secondary benefit. The primary benefit of modular code is maintainability through bounded context.
When you write everything in a shared global scope, every variable is a potential point of failure. You are afraid to delete code, afraid to rename variables, and afraid to refactor, because you never know what distant corner of the application might be relying on that exact naming convention.
Modules give you boundaries. When you look at a file that imports getUser and formatDate, you know exactly what external dependencies that file relies on. And when you look at formatDate.js, you know exactly what it exposes to the outside world.
You aren't just organizing files into neat little folders. You are building explicit, traceable data pipelines through your application. You are moving from a state of "hoping the script loaded first" to a mathematical graph of dependencies.
If you want to write software that survives contact with a team of developers over a span of years, mastering JavaScript modules is step one.

