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 };
FormLoadingWhere it runs
import x from './a.js'Static, resolved before executionModules only
import('./a.js')Dynamic, returns a promiseAnywhere
require('./a.cjs')Synchronous, cached by pathCommonJS
import.meta.urlThe module's own URLModules only
__dirnameNot defined in ESMCommonJS only
⚠️
A dynamic 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 .js files ESM; use .cjs for the CommonJS ones.
  • exports controls exactly which paths an import may reach — anything missing from it is unreachable, even if the file exists.
  • files is an allow-list for publishing; keep build output and tests out of it.
  • "sideEffects": false lets a bundler drop unused modules, but only if importing them truly has no effect.
  • import.meta.env style 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/ structure

Organise 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?
Almost always a missing file extension or a bare package specifier the browser cannot resolve, such as import x from 'lodash' without an import map. Browsers resolve URLs literally.
Should I create a single index.js that re-exports everything?
Only for a package's public entry point. Inside an application, barrel files hide the real dependency graph, create import cycles and slow down bundlers; import the file that actually defines the thing.

Classes, prototypes and object-oriented patterns Tooling: linting, formatting, bundling and testing

Last refreshed 2026-09-18.