ES Modules (import)
ES Modules are JavaScript's standard module system - static import/export, dynamic import(), and top-level await give Node 24 services explicit dependency graphs and async initialization.
Recipe
import { readFile } from 'node:fs/promises';
import type { Server } from 'node:http';
export async function loadConfig(path: string): Promise<Record<string, string>> {
const raw = await readFile(path, 'utf8');
return JSON.parse(raw) as Record<string, string>;
}const mod = await import('./feature.js');When to reach for this:
- All new Node 24 backend services (
"type": "module") - Libraries publishing typed public APIs
- Conditional plugin loading at runtime
- Bootstrapping config before listening on a port (top-level await)
Working Example
// config.ts - top-level await loads before server imports this module
import { readFile } from 'node:fs/promises';
export const config = JSON.parse(
await readFile(new URL('./config.json', import.meta.url), 'utf8'),
) as { port: number; host: string };// server.ts
import { createServer } from 'node:http';
import { config } from './config.js';
const server = createServer((req, res) => {
res.end(`listening config port ${config.port}`);
});
server.listen(config.port, config.host);// plugins/loader.ts
const pluginName = process.env.PLUGIN ?? 'default';
const plugin = await import(`./${pluginName}.js`);
await plugin.register();What this demonstrates:
- Top-level
awaitblocks importers untilconfigis ready - no manual async main wrapper import.meta.urlresolves relative paths in ESM without__dirname- Dynamic
import()enables plugin selection from environment variables - Static imports hoist - they run before module body but after dependency graph load
Deep Dive
How It Works
- Static imports are parsed before execution - circular ESM deps fail fast with clearer errors than CJS.
- Live bindings - named imports are references to exported bindings, not copies (for
let/constexports). - Dynamic import() returns
Promise<Module>- works in CJS via async wrapper and in ESM anywhere. - Import attributes (experimental/stable per version) gate JSON/CSS module types - check Node 24 docs for
with { type: 'json' }.
Import Forms
| Form | When |
|---|---|
import { x } from './a.js' | Static named imports |
import type { T } from './a.js' | Type-only (erased) |
import * as ns from './a.js' | Namespace object |
await import('./a.js') | Runtime conditional loading |
TypeScript Notes
// tsconfig.json for Node 24 ESM
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"verbatimModuleSyntax": true
}
}Use NodeNext so TypeScript enforces .js extensions matching Node resolution.
Gotchas
- Missing
.jsextension in import paths -ERR_MODULE_NOT_FOUNDat runtime aftertsc. Fix:moduleResolution: NodeNextandimport './file.js'. - Top-level await blocking entire app boot - slow config import delays all routes. Fix: lazy dynamic import for heavy optional modules.
- Dynamic import with fully variable paths - bundlers and security scanners cannot analyze. Fix: allowlist plugin names.
- Mixing default and named import incorrectly -
import fs from 'node:fs'fails without default export. Fix:import * as fs from 'node:fs'or named imports. - Circular ESM between barrel files - TDZ errors on uninitialized bindings. Fix: import from leaf modules, not cyclic barrels.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
CommonJS require | Legacy .cjs only | Greenfield services |
createRequire | One-off CJS dep from ESM | Whole app could be ESM |
| Bundler (esbuild) | Single-file deploy artifact | Server runs TS directly with tsx |
import.meta.resolve | Resolve paths spec-compliant | Simple relative imports suffice |
FAQs
Do I need file extensions in imports?
Yes for Node ESM resolution of relative files - use .js in import specifiers even when source is .ts.
What is import.meta.url?
The absolute file:// URL of the current module - use with new URL('./x', import.meta.url).
Can I use top-level await in tests?
Yes in ESM test files run with node --test or Vitest ESM mode.
How do re-exports work?
export { foo } from './foo.js' and export * from './bar.js' build public API surfaces.
Is import hoisted?
Static imports are evaluated before the module body runs, in dependency order.
Can I import JSON?
Node supports JSON modules via import attributes in recent versions - or use readFile + parse for clarity.
What about import.meta.main?
Node 24+ exposes whether the module is the entry point - useful for dual CLI/library files.
How does ESM interact with __dirname?
It does not exist. Use fileURLToPath(import.meta.url) and path.dirname.
Does NestJS 11 support ESM?
Increasingly via SWC and "type": "module" projects - verify decorator/metadata compatibility in your stack.
Can dynamic import be sync?
No - it always returns a Promise. Use static import for sync module graph needs.
What is live binding?
If exporter changes export let count = 1 to 2, importers see 2 when they read count - unlike deep copy.
How do I import built-ins?
import { readFile } from 'node:fs/promises' - no extension, node: prefix recommended.
Related
- package.json type & exports - package entry resolution
- CJS ↔ ESM Interop - mixed module graphs
- Module Resolution Algorithm - how specifiers resolve
- TypeScript in Node Basics - tsconfig for ESM
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.