Before ES Modules, JavaScript had no built-in module system. You had script tags in the right order, IIFE patterns to fake encapsulation, and then a decade of CommonJS in Node.js. ES Modules — standardised in ES2015 and now supported everywhere — are the real solution. This guide covers everything from the basics of named vs default exports to dynamic imports, tree-shaking, and the patterns that keep large codebases maintainable.
Named Exports vs Default Exports
There are two ways to export from a module. Understanding the difference — and the tradeoffs — is the foundation for everything else:
// utils/currency.js
// Named exports — explicit names, multiple per file
export function formatPrice(amount, currency = 'USD') {
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount);
}
export function parseCents(cents) {
return cents / 100;
}
export const DEFAULT_CURRENCY = 'USD';// Importing named exports — names must match
import { formatPrice, parseCents } from './utils/currency.js';
// You can rename on import
import { formatPrice as fmt } from './utils/currency.js';
// Import the entire namespace as an object
import * as Currency from './utils/currency.js';
Currency.formatPrice(49.99); // works// components/ProductCard.js
// Default export — one per file, no name required
export default function ProductCard({ product }) {
return `<div class="card">${product.name}</div>`;
}// Importing a default export — you choose the name
import ProductCard from './components/ProductCard.js';
import Card from './components/ProductCard.js'; // same thing, different nameBarrel Files — Useful and Dangerous
A barrel file is an index.js that re-exports from multiple modules,
giving consumers a cleaner import path. They're everywhere in large projects:
// utils/index.js — the barrel
export { formatPrice, parseCents, DEFAULT_CURRENCY } from './currency.js';
export { validateEmail, validatePhone } from './validation.js';
export { debounce, throttle } from './timing.js';
export { fetchWithRetry, buildQueryString } from './http.js';// Consumer — clean import path, no need to know the file structure
import { formatPrice, debounce, fetchWithRetry } from './utils/index.js';
// or with bundlers that resolve index.js automatically:
import { formatPrice, debounce, fetchWithRetry } from './utils';The danger: barrel files can destroy tree-shaking. If you import one function from a
barrel that re-exports 40 modules, some bundlers pull in all 40 — even the ones you don't use.
The fix is to be selective about what you barrel, and make sure your bundler supports
sideEffects: false in package.json.
Dynamic import() — Code Splitting on Demand
Static imports (import x from '...') are resolved at bundle time.
Dynamic import()
is a function that loads a module lazily at runtime, returning
a Promise. This is how you implement route-based code splitting:
// Load a heavy chart library only when the user navigates to the analytics page
async function loadAnalyticsDashboard() {
const { Chart } = await import('chart.js');
const { buildDashboardData } = await import('./analytics/data.js');
const data = await buildDashboardData();
const canvas = document.getElementById('dashboard-chart');
new Chart(canvas, { type: 'bar', data });
}
// Attach to a route change
router.on('/analytics', loadAnalyticsDashboard);// Feature flags — only load a module if the feature is enabled
async function initFeature(featureName) {
const flags = await getFeatureFlags();
if (flags[featureName]) {
const module = await import(`./features/${featureName}.js`);
module.init();
}
}Webpack, Rollup, and Vite all understand dynamic import() and will
automatically split the loaded module into a separate chunk. The result: smaller initial
bundle, faster first load.
import.meta — Module Metadata
import.meta
is an object that gives you metadata about the current module.
The most useful property is import.meta.url — the URL of the current module file:
// Get the directory of the current module (Node.js)
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Now you can do path resolution the same way you would with CommonJS
const configPath = join(__dirname, '../config/app.json');// In Vite projects, import.meta.env gives you environment variables
const apiBase = import.meta.env.VITE_API_BASE_URL;
const isProd = import.meta.env.PROD; // boolean
// import.meta.glob — Vite-specific, import multiple files at once
const modules = import.meta.glob('./pages/*.js');
for (const path in modules) {
const module = await modules[path]();
console.log(path, module);
}ES Modules vs CommonJS
You'll still encounter CommonJS (require()) in older Node.js codebases
and some npm packages. Here's a quick reference for the differences that trip people up:
// CommonJS (CJS) — still common in Node.js
const express = require('express');
const { join } = require('path');
module.exports = { myFunction };
module.exports.default = MyClass;
// ES Modules (ESM) — the standard
import express from 'express';
import { join } from 'path';
export { myFunction };
export default MyClass;Key differences: ES Modules are statically analysable (imports are resolved before
code runs), while CommonJS requires execute at runtime. ESM uses async loading by
default; CJS is synchronous. In Node.js,
files with .mjs extension or
"type": "module" in package.json are treated as ESM.
import a CommonJS module from
an ESM file (Node.js handles this), but you cannot require() an ESM module from
a CommonJS file. If you need to, use a dynamic import() call instead — it works
in both module systems.Tree-Shaking and Named Exports
Tree-shaking is the bundler process of removing code you import but never use. It requires named exports and static import statements — dynamic imports and CommonJS are much harder to tree-shake. Here's how to write modules that tree-shake well:
// ✅ Tree-shakeable — bundler can see exactly what's exported
export function formatDate(date) { /* ... */ }
export function parseDate(str) { /* ... */ }
export function addDays(date, n) { /* ... */ }
// If you only import formatDate, parseDate and addDays are excluded from the bundle
import { formatDate } from './utils/dates.js';// ❌ Harder to tree-shake — the whole object is one export
const dateUtils = {
formatDate: (date) => { /* ... */ },
parseDate: (str) => { /* ... */ },
addDays: (date, n) => { /* ... */ }
};
export default dateUtils;
// Even if you only use dateUtils.formatDate, the whole object may be bundledFor libraries published to npm, tree-shaking support is the difference between a library people recommend and one they avoid. The MDN Modules guide covers the full static analysis rules that enable it.
Native ES Modules in the Browser
You can use ES Modules directly in the browser — no bundler required. Add
type="module" to your script tag:
<!-- index.html -->
<!-- type="module" enables ES Module semantics -->
<!-- deferred by default — runs after HTML is parsed -->
<script type="module" src="./app.js"></script>// app.js — can use import/export directly
import { initRouter } from './router.js';
import { setupAuth } from './auth.js';
import { loadDashboard } from './dashboard.js';
async function main() {
await setupAuth();
initRouter();
await loadDashboard();
}
main();Module scripts are always deferred and run in strict mode by default. Each module is only executed once, even if imported by multiple other modules — the browser caches the module instance. This is worth knowing when you have shared state in modules.
A Real Refactoring Example
Here's how a typical utils file grows out of control and gets split properly into modules:
// Before: one giant utils.js with everything
export function formatPrice(amount) { /* ... */ }
export function validateEmail(email) { /* ... */ }
export function debounce(fn, ms) { /* ... */ }
export function fetchWithRetry(url, opts) { /* ... */ }
export function parseCSV(text) { /* ... */ }
export function buildQueryString(params) { /* ... */ }
// After: split by concern
// utils/
// currency.js → formatPrice, parseCents
// validation.js → validateEmail, validatePhone
// timing.js → debounce, throttle
// http.js → fetchWithRetry, buildQueryString
// csv.js → parseCSV, formatCSV
// index.js → re-exports all of the above (optional barrel)Splitting by concern means each file has a single responsibility, tests are easier to write and locate, and bundlers can tree-shake at the file level. The barrel file keeps imports clean for consumers who prefer the single import path.
Useful Tools
When building modular JavaScript, JS Formatter keeps your import blocks clean and consistent. For understanding what's in your final bundle, tools like Webpack Bundle Analyzer or Rollup's visualiser show which modules are taking up space. The TC39 ECMAScript Modules spec has the exact resolution semantics if you need to understand edge cases.
Wrapping Up
ES Modules are the foundation of modern JavaScript architecture. Prefer named exports
for utility functions — they're explicit, tree-shakeable, and IDE-friendly. Use dynamic
import() to split heavy code out of your initial bundle. Be careful with barrel
files in large codebases. And if you're still on CommonJS in a new Node.js project, it's
time to move — the ecosystem has fully shifted to ESM.