How to use ESM modules in Node.js
Node.js traditionally used CommonJS modules with require() and module.exports, but modern JavaScript uses ES modules with import and export syntax.
With over 10 years of experience building Node.js applications and as the creator of CoreUI, I’ve migrated numerous projects from CommonJS to ES modules to leverage modern JavaScript features.
From my expertise, the most straightforward approach is to add "type": "module" to your package.json, which enables ESM by default for all .js files.
This method aligns your Node.js code with browser JavaScript and modern tooling.
Add "type": "module" to package.json to enable ES modules in Node.js.
{
"name": "my-app",
"version": "1.0.0",
"type": "module"
}
How It Works
Setting "type": "module" tells Node.js to treat all .js files as ES modules, allowing you to use import and export statements. Without this setting, Node.js defaults to CommonJS and would require .mjs file extensions for ES modules. This single configuration enables modern module syntax across your entire project.
Using Import and Export
With ESM enabled, use standard import and export syntax:
// utils.js
export const greet = (name) => {
return `Hello, ${name}!`
}
export const farewell = (name) => {
return `Goodbye, ${name}!`
}
// app.js
import { greet, farewell } from './utils.js'
console.log(greet('World'))
console.log(farewell('World'))
The export keyword makes functions, objects, or values available to other modules. The import statement brings them into the current file. Note that you must include the .js file extension in ESM imports, unlike CommonJS where extensions are optional.
Default Exports
Use default exports for a single primary export from a module:
// database.js
export default class Database {
connect() {
console.log('Connected to database')
}
}
// app.js
import Database from './database.js'
const db = new Database()
db.connect()
A module can have only one default export, but it can have multiple named exports alongside it. When importing a default export, you can use any name you like - it doesn’t have to match the original name.
Importing Node.js Built-in Modules
Import built-in Node.js modules using the same syntax:
import { readFile } from 'fs/promises'
import { join } from 'path'
import http from 'http'
const data = await readFile(join('data', 'config.json'), 'utf-8')
const config = JSON.parse(data)
http.createServer((req, res) => {
res.end('Hello World')
}).listen(3000)
Node.js built-in modules work seamlessly with ESM. You can use both named imports (like readFile) and default imports (like http). The fs/promises module provides promise-based versions of file system operations, which work well with async/await.
Top-Level Await
ESM enables top-level await without wrapping in an async function:
import { readFile } from 'fs/promises'
const config = await readFile('config.json', 'utf-8')
const settings = JSON.parse(config)
console.log(settings.port)
This is a major advantage of ESM - you can use await at the top level of a module without an async function wrapper. In CommonJS, you would need to wrap this code in an async IIFE (Immediately Invoked Function Expression).
Importing JSON Files
Import JSON files using import assertions:
import config from './config.json' with { type: 'json' }
console.log(config.database.host)
The with { type: 'json' } assertion tells Node.js to parse the file as JSON. This is required in ESM for security reasons, ensuring that only explicitly marked files are parsed as JSON. In older Node.js versions, you might use assert instead of with.
Mixing ESM and CommonJS
If you need to use a CommonJS module from ESM code, import it normally:
// CommonJS library
import express from 'express'
const app = express()
app.get('/', (req, res) => {
res.send('Hello from ESM!')
})
app.listen(3000)
Most npm packages now support both CommonJS and ESM. When importing a CommonJS module from ESM, Node.js automatically wraps it so it works with import. However, you can only use default imports, not named imports, for CommonJS modules.
Using __dirname and __filename
ESM doesn’t provide __dirname and __filename, but you can recreate them:
import { fileURLToPath } from 'url'
import { dirname } from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
console.log('Current file:', __filename)
console.log('Current directory:', __dirname)
The import.meta.url provides the current module’s URL, which starts with file://. The fileURLToPath function converts this URL to a regular file path. This is the ESM equivalent of the __dirname and __filename globals from CommonJS.
Best Practice Note
This is the same ES modules setup we use in CoreUI Node.js build tools to align with modern JavaScript standards. ESM provides better tree-shaking for smaller bundles, static analysis for better tooling, and async module loading. When migrating from CommonJS, test thoroughly as some edge cases behave differently - for example, circular dependencies are handled differently in ESM. For production applications, ensure your Node.js version is 14.13.0 or higher for full ESM support. Consider reading our guide on how to import JSON in Node.js for more details on working with data files in ESM.



