Modules and project structure
ES module import and export, default versus named exports, dynamic import, CommonJS interop, folder layout and the package.json fields that matter.
Import and export
An ES module has its own scope, is always strict, and runs once no matter how many times it is imported. Imports are live bindings resolved before the module body executes, which is why they cannot appear inside an if block.
// lib/money.js
export const CURRENCY = 'GBP';
export function format(amount) {
return CURRENCY + ' ' + amount.toFixed(2);
}
export class Invoice { constructor(total) { this.total = total; } }
export default function invoiceFor(order) { // one default per module
return new Invoice(order.total);
}
// consumer
import invoiceFor, { format, CURRENCY as cur, Invoice } from './lib/money.js';
import * as money from './lib/money.js'; // namespace object
import './lib/polyfill.js'; // side effects only
// re-export a curated surface
export { format, Invoice };
export * from './lib/rates.js';- Prefer named exports: they rename safely, tree-shake well and make imports greppable.
- Use a default export only when a module has one obvious thing to offer, such as a React component file.
- Imports are hoisted and read-only — assigning to an imported binding is a syntax error.
- Include the file extension in browser imports; bundlers are lenient, browsers are not.
Dynamic import and CommonJS interop
import() looks like a function call and returns a promise for the module namespace. Use it to split code that is not needed on first paint, and to load optional features only when a user asks for them.
// code splitting at a natural boundary
button.addEventListener('click', async () => {
const { openEditor } = await import('./editor.js');
openEditor();
});
// lazy-load by locale, with a fallback
async function loadLocale(tag) {
const supported = ['en', 'fr', 'de'];
const chosen = supported.includes(tag) ? tag : 'en';
const mod = await import(`./locales/${chosen}.js`);
return mod.default;
}
// CommonJS interop: the default export is the module.exports object
import legacy from './legacy.cjs';
legacy.doThing();
// and the other direction
const { readFile } = require('node:fs/promises');
// top-level await: only inside a module, blocks importers until it settles
const config = await fetch('/config.json').then(r => r.json());
export { config };| Form | Loading | Where it runs |
|---|---|---|
import x from './a.js' | Static, resolved before execution | Modules only |
import('./a.js') | Dynamic, returns a promise | Anywhere |
require('./a.cjs') | Synchronous, cached by path | CommonJS |
import.meta.url | The module's own URL | Modules only |
__dirname | Not defined in ESM | CommonJS only |
import() with a computed path (such as a user-supplied string) can pull in files you never intended to ship and makes bundling impossible. Keep the set of specifiers statically analysable — match on a small allow-list, as above.Folders and package.json
{
"name": "shop-web",
"version": "0.4.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./package.json": "./package.json"
},
"files": ["dist"],
"sideEffects": false,
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"test": "vitest"
}
}"type": "module"makes.jsfiles ESM; use.cjsfor the CommonJS ones.exportscontrols exactly which paths an import may reach — anything missing from it is unreachable, even if the file exists.filesis an allow-list for publishing; keep build output and tests out of it."sideEffects": falselets a bundler drop unused modules, but only if importing them truly has no effect.import.meta.envstyle variables come from the build tool, not from Node; keep secrets out of them.
src/
main.js entry point, wires things together
api/client.js one job: talk to the network
ui/ components and their styles
lib/ framework-free helpers
config.js env-derived settings
tests/ mirrors src/ structureOrganise by feature as the project grows: put the component, its styles, its tests and its data access in one folder instead of scattering them across four type-based folders. Dependencies should point one way — ui may import from lib, never the reverse.
FAQ
Why does my import fail in the browser but work in Node?
import x from 'lodash' without an import map. Browsers resolve URLs literally.Should I create a single index.js that re-exports everything?
Related
Classes, prototypes and object-oriented patterns Tooling: linting, formatting, bundling and testing
Last refreshed 2026-09-18.