Change how Node.js finds and loads modules with module.registerHooks(). Write resolve and load hooks for aliases, text imports and code transforms.

Bishal Biswas
Self Learner
What are module hooks?
Every time your code says import x from 'something' or require('something'), Node does two jobs:
- Resolve: work out which file
'something'means, as a full URL likefile:///home/asha/app/node_modules/something/index.js. - Load: read that file and decide what kind of code it is (an ES module, CommonJS, JSON and so on).
Module customisation hooks let you step into either job and change the answer.
Think of a library. You ask the desk for “the blue cookbook”. The librarian looks up where it is shelved (resolve), then fetches it (load). A hook is like standing next to the librarian and saying “when someone asks for the blue cookbook, give them the new edition” or “when you fetch a recipe card, photocopy it onto A4 first”.
This is how tools add features to Node itself: importing .txt or .yaml files, path aliases like @app/utils, running languages that compile to JavaScript, or measuring test coverage.
The API: module.registerHooks()
In Node 24, the recommended API is registerHooks() from node:module. It was added in Node 22.15 and 23.5, and current Node 24 releases mark it as a release candidate, meaning it is close to stable.
import { registerHooks } from 'node:module';
registerHooks({
resolve(specifier, context, nextResolve) {
// decide which URL a specifier points to
return nextResolve(specifier, context);
},
load(url, context, nextLoad) {
// decide what source code a URL contains
return nextLoad(url, context);
},
});
Both hooks are synchronous (plain functions, no async) and run in-thread, on the same thread as your app. They apply to import, import() and require() alike.
Each hook receives a next... function. Calling it means “do whatever would normally happen”. Hooks form a chain: if several are registered, each one can pass the job along to the next, and the last one in the chain is Node’s built-in behaviour.
A load hook: importing text files
Out of the box, Node cannot import a .txt file:
// app.mjs
import letter from './letter.txt';
console.log(letter);
TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".txt"
A load hook can turn the text into a tiny JavaScript module on the fly:
// hooks.mjs
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
registerHooks({
load(url, context, nextLoad) {
if (url.endsWith('.txt')) {
const text = readFileSync(new URL(url), 'utf8');
return {
format: 'module', // treat the result as an ES module
source: `export default ${JSON.stringify(text)};`,
shortCircuit: true, // we are done, do not call nextLoad
};
}
return nextLoad(url, context); // every other file loads normally
},
});
load returns an object with:
source: the code (a string or buffer).format: what kind of code it is, such as'module','commonjs'or'json'.shortCircuit: true: needed when you return without callingnextLoad, to show that skipping the rest of the chain is deliberate.
Registering hooks before your app
Hooks must be registered before the modules they affect are loaded. The simplest way is the --import flag, which runs a file before your entry point:
node --import ./hooks.mjs app.mjs
Dear Asha,
Your order has shipped.
--require ./hooks.mjs works too. Either way, the hooks are active before the first line of app.mjs is even read.
To avoid typing the flag every time, put it in a package.json script:
{
"scripts": {
"start": "node --import ./hooks.mjs app.mjs"
}
}
A resolve hook: path aliases
Deep relative imports like ../../../lib/greet.mjs are hard to read. A resolve hook can map a prefix such as @app/ to your src folder:
// alias-hooks.mjs
import { registerHooks } from 'node:module';
const srcUrl = new URL('./src/', import.meta.url);
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier.startsWith('@app/')) {
const url = new URL(specifier.slice('@app/'.length), srcUrl).href;
return { url, shortCircuit: true };
}
return nextResolve(specifier, context);
},
});
// app.mjs
import { greet } from '@app/lib/greet.mjs'; // means ./src/lib/greet.mjs
console.log(greet('Ravi'));
node --import ./alias-hooks.mjs app.mjs
Hello, Ravi
resolve receives:
specifier: the exact text in theimportorrequire.context.parentURL: the URL of the file doing the importing.nextResolve: Node’s normal resolution (or the next hook).
It returns at least a url, and can also return a format hint.
Tip: For simple aliases you may not need a hook at all. The
"imports"field inpackage.jsonsupports aliases starting with#, like#lib/greet.mjs, natively. Hooks are for when you need logic that field cannot express.
Transforming code
A load hook can also call nextLoad first and then change the source. This is how compilers plug into Node. Here is a toy one that adds a log line to the top of every one of your own ES modules:
// transform-hooks.mjs
import { registerHooks } from 'node:module';
registerHooks({
load(url, context, nextLoad) {
const result = nextLoad(url, context); // let Node read the file first
if (url.startsWith('file:') && !url.includes('/node_modules/') && result.format === 'module') {
const banner = `console.log('[loaded] ${url.split('/').pop()}');\n`;
return { ...result, source: banner + String(result.source) };
}
return result;
},
});
[loaded] greet.mjs
[loaded] app.mjs
Hello, Maya
Notice the order: greet.mjs runs before app.mjs, because a module’s imports are evaluated before the module itself.
Note:
result.sourcemay be aBuffer, so convert it withString()before editing it.
Removing hooks
registerHooks() returns an object with a deregister() method. After calling it, new imports go back to normal behaviour:
const hooks = registerHooks({ /* ... */ });
// later
hooks.deregister();
Modules already loaded stay loaded. Node caches them.
The older API: module.register()
You may meet older code that uses module.register() with hooks in a separate file:
import { register } from 'node:module';
register('./async-hooks.mjs', import.meta.url); // the hooks run on a separate thread
That API runs asynchronous hooks on a separate thread. Having them on another thread caused many surprises: hooks could not share variables with your app, and some require() paths were not covered. The even older --experimental-loader flag used the same off-thread design.
- In Node 24,
module.register()still works, but recent Node 24 docs mark it as deprecated. - In Node 26, it is runtime-deprecated: using it prints a deprecation warning.
For new code, use module.registerHooks(). If you maintain a loader built on register(), plan to move it over: make the hooks synchronous (use readFileSync and friends instead of await) and register them with registerHooks().
Common mistakes
Registering hooks after the import
// app.mjs
import './hooks.mjs';
import letter from './letter.txt'; // still fails: ERR_UNKNOWN_FILE_EXTENSION
Static imports are all resolved and loaded before any of them run, so the hook is registered too late. Use node --import ./hooks.mjs app.mjs, or register first and then use a dynamic await import('./letter.txt').
Making the hook async
registerHooks({
async load(url, context, nextLoad) { /* ... */ }, // wrong: returns a promise
});
registerHooks() hooks must return a plain object, not a promise. An async hook makes Node fail with an ERR_INVALID_RETURN_PROPERTY_VALUE error, because the promise has no source. Use synchronous APIs like readFileSync inside them.
Forgetting shortCircuit
If your hook returns its own result without calling next..., add shortCircuit: true. Without it, Node stops with an error:
TypeError [ERR_INVALID_RETURN_PROPERTY_VALUE]: Expected true to be returned for the "shortCircuit" from the "load" hook but got undefined.
Catching every file
load(url, context, nextLoad) {
return { format: 'module', source: transform(nextLoad(url, context).source) }; // for EVERYTHING
}
The hook can also receive built-in modules (node: URLs), JSON files and everything in node_modules. Check the URL (extension, file: scheme, folder) and pass everything else straight to nextLoad.
Quick recap
- Node resolves a specifier to a URL, then loads that URL as source code. Hooks can change either step.
- Use
module.registerHooks({ resolve, load }): synchronous, in-thread, and it covers bothimportandrequire. - Call
nextResolve/nextLoadfor default behaviour, or return your own result withshortCircuit: true. - Register hooks before your app with
node --import ./hooks.mjs app.mjs. - Classic uses: importing new file types, path aliases and transforming code.
module.register()runs async hooks on another thread and is runtime-deprecated in Node 26. PreferregisterHooks().
// hooks-demo.mjs: run with node hooks-demo.mjs
// Customises how Node finds (resolve) and reads (load) modules, all in one file.
import { registerHooks } from 'node:module';
import { writeFileSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
// Data for a made-up "virtual" module that exists only in memory
const virtualModules = {
'virtual:shop-config': `export default { shop: 'Maya Books', currency: 'GBP' };`,
'virtual:build-info': `export const builtAt = ${JSON.stringify(new Date().toISOString())};`,
};
const hooks = registerHooks({
// resolve: turn a specifier (the text after "from") into a URL
resolve(specifier, context, nextResolve) {
if (specifier in virtualModules) {
return { url: specifier, shortCircuit: true }; // keep the name as the URL
}
return nextResolve(specifier, context); // everything else: default behaviour
},
// load: turn a URL into source code and say what format it is
load(url, context, nextLoad) {
if (url in virtualModules) {
return { format: 'module', source: virtualModules[url], shortCircuit: true };
}
if (url.endsWith('.txt')) {
// Let Node read the file, then wrap the text as a default export
const { source } = nextLoad(url, { ...context, format: 'module' });
const text = String(source);
return { format: 'module', source: `export default ${JSON.stringify(text)};`, shortCircuit: true };
}
return nextLoad(url, context);
},
});
// --- Use the hooks. Imports must happen AFTER registerHooks, so use import() ---
const { default: config } = await import('virtual:shop-config');
const { builtAt } = await import('virtual:build-info');
console.log(`Shop: ${config.shop} (${config.currency}), built at ${builtAt}`);
// Write a .txt file, then import it like a module
const dir = mkdtempSync(join(tmpdir(), 'hooks-'));
const letterPath = join(dir, 'letter.txt');
writeFileSync(letterPath, 'Dear Asha,\nYour order has shipped.\n');
const { default: letter } = await import(pathToFileURL(letterPath).href);
console.log('Imported text file:\n' + letter.trim());
// Hooks can be removed again: a new .txt import now fails as normal
hooks.deregister();
const notePath = join(dir, 'note.txt');
writeFileSync(notePath, 'Call Ravi');
try {
await import(pathToFileURL(notePath).href);
} catch (err) {
console.log('After deregister:', err.code);
}
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready