Fixing '__dirname is not defined' in ES Modules
CommonJS gives every module five free variables — require, module, exports, __filename and __dirname. ES modules have none of them, so the first time a file that reads a template, a config or a fixture relative to itself runs as ESM, it crashes with ReferenceError: __dirname is not defined in ES module scope. The fix is small, but the right fix depends on your Node.js version, whether the code is bundled, and whether it ships in a dual-format package. This guide covers all three.
Exact symptoms and error messages
The error is thrown when the line referencing the variable executes, not at import time, so it can hide in a rarely used code path:
file:///app/dist/server.js:12
const templatesDir = path.join(__dirname, 'templates');
^
ReferenceError: __dirname is not defined in ES module scope
This file is being treated as an ES module because it has a '.js' file extension and
'/app/package.json' contains "type": "module". To treat it as a CommonJS script, rename it to use the '.cjs' file extension.
at file:///app/dist/server.js:12:32
The siblings fail the same way:
ReferenceError: __filename is not defined in ES module scope
ReferenceError: require is not defined in ES module scope, you can use import instead
ReferenceError: module is not defined in ES module scope
A subtler variant appears in bundled output: the code runs, but __dirname points at the bundle's output folder rather than the original source folder, and file reads fail with ENOENT on paths that look almost right.
Root cause analysis
The CommonJS loader wraps every file in a function before running it — (function (exports, require, module, __filename, __dirname) { ... }) — and passes those five values as arguments. That is why they look like globals but differ per file. ES modules are not wrapped. Instead, each module gets a per-module object, import.meta, whose url property is the module's own file:// URL. Everything __dirname used to provide can be derived from it. How the two loaders differ more broadly is covered in ESM and CJS Interoperability.
Node.js 20.11 and 21.2 added import.meta.dirname and import.meta.filename as direct replacements, so on current LTS versions the fix is a one-word change. On older runtimes you derive the same values from import.meta.url with fileURLToPath.
Resolution and configuration patch
Node.js 20.11 and later
import { readFile } from 'node:fs/promises';
import path from 'node:path';
const templatesDir = path.join(import.meta.dirname, 'templates');
const html = await readFile(path.join(templatesDir, 'email.html'), 'utf8');
If your engines.node field already requires 20.11 or later, this is the whole fix. Update @types/node to a version that declares import.meta.dirname so TypeScript accepts it.
Node.js 18 support
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
Always use fileURLToPath, never new URL(import.meta.url).pathname. The pathname form keeps percent-encoding (a space becomes %20) and produces /C:/Users/... on Windows, both of which break fs calls.
Reading files without computing a directory
Many uses of __dirname exist only to build a path for fs. Node's fs functions accept URL objects directly, so you can skip the path arithmetic:
import { readFile } from 'node:fs/promises';
const schema = JSON.parse(
await readFile(new URL('./schema.json', import.meta.url), 'utf8')
);
This pattern has an extra benefit in bundled code: Vite, webpack 5 and esbuild (with the right loader) recognise new URL('./asset', import.meta.url) and copy the asset into the output, rewriting the URL. A path.join(__dirname, 'asset') call is invisible to them.
Replacing require and require.resolve
createRequire rebuilds a CommonJS require function anchored to the current file — useful for loading JSON or a CommonJS-only package:
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const pkg = require('./package.json');
const legacyPlugin = require('legacy-cjs-plugin');
For resolution without loading, import.meta.resolve('some-lib') returns the resolved URL synchronously on Node.js 20 and later.
Dual-format packages and build tools
A package that ships both ESM and CommonJS builds from one source has a harder problem: import.meta is a syntax error in CommonJS output, and __dirname is undefined in ESM output. Three approaches work:
- Let the bundler shim it. tsup and esbuild can inject
import.meta.urlequivalents into CommonJS output (tsup'sshims: true) so source code can useimport.meta.urleverywhere. - Isolate the path logic. Put the directory lookup in two tiny files,
paths.mjsandpaths.cjs, selected by theexportsmap'simportandrequireconditions. - Avoid runtime paths. Import JSON with import attributes (
import data from './data.json' with { type: 'json' }) or inline small assets at build time, so no directory lookup is needed.
The dual-build setup itself is covered in How to Configure package.json for Dual Modules.
Worked example: migrating a CLI that loads templates
A project scaffolding CLI written in CommonJS reads template folders with path.join(__dirname, '..', 'templates', name) and copies them into the user's project. The team moves the package to "type": "module" so it can depend on an ESM-only prompt library. The first run after the switch crashes on the template lookup.
The migration takes four small steps. First, the path helper becomes a single exported function so the logic lives in one place:
// src/paths.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
export const templatesRoot = path.resolve(here, '..', 'templates');
Second, every call site imports templatesRoot instead of computing its own path, which removes a dozen copies of the same arithmetic. Third, the package's files field is checked to confirm templates/ is still published — the move to ESM is often combined with a new build step, and a new dist/ layout can change which directories sit next to the running file. Fourth, a CI job installs the packed tarball globally and runs the CLI against a temporary directory, exercising the code path that reads templates.
Once the project's Node.js floor moves to 20.11, the helper shrinks to path.resolve(import.meta.dirname, '..', 'templates'), and because the logic is centralised, that is a one-line change.
Test runners and __dirname
Test runners add their own twist. Jest transforms ESM sources to CommonJS by default, so __dirname works inside Jest tests even when the same file fails under Node.js — and import.meta can fail under Jest unless you run it in native ESM mode (--experimental-vm-modules). Vitest runs files as ESM and supports import.meta.url and import.meta.dirname; it also defines __dirname in test files for compatibility, which can mask the bug in application code imported by the tests. If your suite passes but production crashes, check which globals the runner injects, and add at least one test that runs the built entry point in a plain Node.js subprocess.
Other CommonJS habits that break in ES modules
__dirname is usually the first failure, but migrations uncover a few related habits that deserve the same treatment while you are in the file.
require.main === module checks. CommonJS scripts use this to run code only when executed directly. The ESM equivalent compares the module URL with the entry script: if (import.meta.url === pathToFileURL(process.argv[1]).href) { main(); }. Node.js 24.2 and later also expose import.meta.main for the same purpose.
Synchronous JSON loading. require('./config.json') becomes an import with an attribute — import config from './config.json' with { type: 'json' } — or a readFile plus JSON.parse when the path is dynamic.
Conditional requires. Code that calls require() inside a function to load an optional dependency lazily can switch to await import(), which also loads lazily. If the surrounding function must stay synchronous, keep a createRequire instance for that one call.
module.exports mutation after load. Some CommonJS modules assign properties to module.exports asynchronously. ES module exports are live bindings fixed at link time, so the pattern must become an exported function or object that is populated explicitly.
CLI validation and debug commands
# Confirm the runtime supports import.meta.dirname
node --input-type=module -e "console.log(import.meta.dirname ?? 'not supported')"
# Find remaining CommonJS-only globals in ESM sources
grep -rnE "\b(__dirname|__filename)\b|\brequire\(" src --include=*.ts --include=*.mts --include=*.js
# Run the built entry and hit the code path that reads files
node dist/server.js --self-test
# Check that bundled assets were copied next to the output
ls dist/templates/ 2>/dev/null || echo "templates not copied"
Prevention and CI/CD guardrails
- Lint for CommonJS globals in ESM files. ESLint's
no-restricted-globalswith__dirname,__filename,requireandmodulecatches them at review time. - Set
engines.nodehonestly. If you rely onimport.meta.dirname, declare>=20.11so older runtimes get a clear warning instead of a runtime crash. - Test built output, not source. Run the file-reading code paths against
dist/in CI, where paths differ fromsrc/. - Prefer
new URL(..., import.meta.url)for assets so bundlers and Node.js agree on where files live.
Frequently Asked Questions
Can I define a global __dirname once for the whole app?
No. __dirname is per-file by definition — each module's directory differs. Compute it in the file that needs it, or pass paths explicitly from a single entry point.
Why does TypeScript accept __dirname in an ESM file?
Because @types/node declares it globally for CommonJS code, and TypeScript does not know which files will run as ESM unless module is node16 or nodenext. The runtime is the first place the error appears.
Does Bun or Deno support __dirname in ES modules?
Bun provides __dirname and import.meta.dir in both formats. Deno supports import.meta.dirname. Code that must run on Node.js as well should use import.meta.dirname or the fileURLToPath pattern.
Related
- ESM and CJS Interoperability explains the loader differences behind these errors.
- Fixing ERR_REQUIRE_ESM in Node.js covers the opposite direction, when CommonJS loads ESM.
- Loading ESM from CommonJS with require(esm) describes newer Node.js interop that reduces the need for migrations.
- Building a Library with Vite Library Mode shows how bundlers handle assets referenced through
import.meta.url.