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:

js
// 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';
js
// 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
js
// components/ProductCard.js

// Default export — one per file, no name required
export default function ProductCard({ product }) {
  return `<div class="card">${product.name}</div>`;
}
js
// Importing a default export — you choose the name
import ProductCard from './components/ProductCard.js';
import Card        from './components/ProductCard.js'; // same thing, different name
Opinion: Named exports are almost always better for utility modules. They're explicit, IDE-friendly (autocomplete and "go to definition" work perfectly), and refactoring-safe — renaming a function is caught by your tools, not your tests. Default exports make sense for components that are "the one thing" a file exports, like React/Angular components or route handler files.

Barrel 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:

js
// 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';
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:

js
// 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);
js
// 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:

js
// 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');
js
// 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:

js
// 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.

Interop gotcha: You can 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:

js
// ✅ 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';
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 bundled

For 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:

js
<!-- index.html -->
<!-- type="module" enables ES Module semantics -->
<!-- deferred by default — runs after HTML is parsed -->
<script type="module" src="./app.js"></script>
js
// 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:

js
// 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.