ES modules (ESM) – the modern way to split JavaScript into files with explicit imports and exports. The goal is a workflow you can use in the browser today and in tooling later, without falling back to dozens of <script> tags in the wrong order.
Why modules matter
Beginner tutorials often put everything in one file. That is fine for learning. Real projects grow: utilities here, UI logic there, API helpers somewhere else. Modules let each file declare what it shares and what it needs from elsewhere. Dependencies become visible instead of hidden globals.
Export and import syntax
A module file can export values. Other files import them by path. Named exports are the default style – export several things from one file and import only what you need.
// utils/format.js
export function formatPrice(amount, currency = 'GBP') {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency,
}).format(amount);
}
export const TAX_RATE = 0.2;
// main.js
import { formatPrice, TAX_RATE } from './utils/format.js';
const total = 49.99 * (1 + TAX_RATE);
console.log(formatPrice(total));
Notice the .js extension in the import path. Browsers require it for native ESM. Node and bundlers often let you omit it, but in the browser, include it.
Default exports
A file can have one default export – useful when a module mainly exposes a single class or function. Import it without curly braces, but you can rename it freely.
// cart.js
export default class Cart {
constructor() {
this.items = [];
}
add(item) {
this.items.push(item);
}
}
import Basket from './cart.js';
const basket = new Basket();
basket.add({ id: 1, name: 'Notebook' });
Rule of thumb: prefer named exports for utilities and shared constants. Use a default export when the file is clearly ‘the Cart module’ or ‘the App entry point’. Mixing both in one file is allowed but can confuse readers – pick a style and stick to it in a project.
Running ESM in the browser
Native modules need type="module" on the script tag. Module scripts are deferred by default and run in strict mode.
<!-- index.html -->
<script type="module" src="./main.js"></script>
You cannot use bare import in a classic script (without type="module"). If you see ‘Cannot use import statement outside a module’, check the script tag.
Opening the HTML file directly from disk (file://) often breaks module loading because browsers treat local imports as cross-origin. Use a simple local server – VS Code’s Live Server extension, python -m http.server, or similar – so paths resolve over http://localhost.
Re-exporting and barrel files
Sometimes you want one import path for several modules. A ‘barrel’ file re-exports from elsewhere:
// utils/index.js
export { formatPrice, TAX_RATE } from './format.js';
export { debounce } from './debounce.js';
import { formatPrice, debounce } from './utils/index.js';
Barrels tidy imports in larger projects. Do not barrel everything on day one – a flat structure with direct imports is easier to follow while the codebase is small.
Dynamic import()
import() returns a promise and loads a module on demand. Useful for code that is not needed on first paint – admin panels, heavy chart libraries, language packs.
const loadChart = async () => {
const { renderChart } = await import('./chart.js');
renderChart(document.querySelector('#chart'));
};
document.querySelector('#show-chart').addEventListener('click', loadChart);
Modules vs script tags
- Each module runs once; top-level bindings are not globals on
window. - Top-level
awaitis allowed in modules (handy for config fetches). - Circular imports are possible but awkward – structure files so utilities do not import from UI layers that import them back.
- Classic scripts and module scripts can coexist, but avoid relying on implicit load order across the two styles.
The next tutorial looks at arrays and objects properly – map, filter, reduce, and immutability habits that pair well with modular code.

