On this page

Module customization hooks let you change how Node.js resolves and loads modules, without touching the modules themselves. A resolve hook decides what a specifier such as './app' or 'lodash' points to, and a load hook decides what source code and format the resulting URL has. With them you can:

  • add support for file types Node.js doesn’t load natively, such as JSX, SVG, CoffeeScript or .txt files;
  • serve virtual modules that don’t exist on disk;
  • redirect or alias specifiers;
  • mock modules in tests.

Hooks are registered with module.registerHooks(), which is available in Node.js 22.15.0 and later (and 23.5.0 and later). It takes synchronous hooks that run on the same thread that loads your modules. Some hooks need to wait on something asynchronous, like a network request; that case is covered below.

For the full hook reference, including the context objects and every return value, see the module API documentation.

A hook does synchronous work if it can compute its result and return it without waiting. Rewriting a specifier, matching a file extension, reading a file with readFileSync() and transforming source text with a synchronous compiler all qualify. Most hooks fall into this category.

Pass resolve and/or load functions to registerHooks(). Each receives the usual arguments plus a nextResolve() or nextLoad() function that calls the next hook in the chain (or Node.js’s default behavior), and returns its result directly:

// register.js
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',
        shortCircuit: true,
        source: `export default ${JSON.stringify(text)};`,
      };
    }
    return nextLoad(url, context);
  },
});
// app.js
import greeting from './greeting.txt';

console.log(greeting); // The contents of greeting.txt

Run your app with the registration file preloaded, so that the hooks are in place before any of your modules load:

A resolve hook works the same way: it receives (specifier, context, nextResolve), and either returns its own { url, shortCircuit: true } or calls nextResolve(). You can also put the hooks in their own file and import them into the registration file.

Hooks can also run a compiler synchronously. This one makes .jsx files importable, using esbuild’s synchronous transformSync() to compile them:

// register.js
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { transformSync } from 'esbuild';

registerHooks({
  load(url, context, nextLoad) {
    if (url.endsWith('.jsx')) {
      const source = readFileSync(new URL(url), 'utf8');
      const { code } = transformSync(source, {
        loader: 'jsx',
        jsx: 'automatic',
        format: 'esm',
        sourcefile: new URL(url).pathname,
      });
      return { format: 'module', shortCircuit: true, source: code };
    }
    return nextLoad(url, context);
  },
});
// Greeting.jsx
export function Greeting({ name }) {
  return <h1>Hello, {name}!</h1>;
}
// app.jsx
import { renderToStaticMarkup } from 'react-dom/server';
import { Greeting } from './Greeting.jsx';

console.log(renderToStaticMarkup(<Greeting name="world" />)); // <h1>Hello, world!</h1>

The same approach works for any language that compiles to JavaScript. For example, a hook for CoffeeScript, using the coffeescript package:

// register.js
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import CoffeeScript from 'coffeescript';

registerHooks({
  load(url, context, nextLoad) {
    if (url.endsWith('.coffee')) {
      const source = readFileSync(new URL(url), 'utf8');
      return {
        format: 'module',
        shortCircuit: true,
        source: CoffeeScript.compile(source, { bare: true }),
      };
    }
    return nextLoad(url, context);
  },
});
# greet.coffee
export greet = (name) -> "Hello, #{name}!"
// app.js
import { greet } from './greet.coffee';

console.log(greet('world')); // Hello, world!

Because hooks run on your application’s thread, they share its globals, so state can live in a closure or a module-level variable:

import { registerHooks } from 'node:module';

const prefix = '>> ';

registerHooks({
  resolve(specifier, context, nextResolve) {
    console.log(prefix + 'resolving', specifier);
    return nextResolve(specifier, context);
  },
});

A slow hook blocks your application, so keep them fast.

For a collection of robust, ready-made hooks and more examples, see @nodejs-loaders.

Hooks run for import, require() and createRequire(), so they see CommonJS modules as well as ES modules. If your hook assumes every module is ESM, check context.format or context.conditions.

Hooks registered with registerHooks() apply only to the thread that registered them. To use them in worker threads, either call registerHooks() in each worker, or register them in a file preloaded with --import (or --require); workers inherit those flags through process.execArgv:

Hooks only affect modules loaded after they are registered. Preloading with --import guarantees this. If you register inside your application, load the customized modules afterward with a dynamic import():

import './register.js';

const { default: greeting } = await import('./greeting.txt');

A hook does asynchronous work if it has to wait for something before it can return, such as a network request or a call into a library that only offers a promise-based API. registerHooks() can’t wait on a promise: its hooks must return their results directly. There are two ways to get that work done anyway, both of which run it off your main thread.

module-register-ponyfill lets you write hooks as a module whose resolve and load functions are async. Its register() function runs that module on a worker thread, and uses registerHooks() and Atomics to make the results available to the loader synchronously. Install it with npm install module-register-ponyfill, then write the hooks module:

// hooks.js
export async function resolve(specifier, context, nextResolve) {
  if (specifier.startsWith('https://')) {
    return { url: specifier, shortCircuit: true };
  }
  return nextResolve(specifier, context);
}

export async function load(url, context, nextLoad) {
  if (url.startsWith('https://')) {
    const response = await fetch(url);
    return {
      format: 'module',
      source: await response.text(),
      shortCircuit: true,
    };
  }
  return nextLoad(url, context);
}

And register it:

// register.js
import { register } from 'module-register-ponyfill';

register('./hooks.js', import.meta.url);

Now app.js can import a module from an https:// URL. The hooks module runs on its own thread, so it has its own global scope and doesn’t share state with your application.

The ponyfill has a few limitations; see its known divergences. For example, hooks registered by one register() call don’t affect how later hooks modules are loaded, so a hook that enables JSX won’t let you write the next hooks module in JSX.

everysync lets synchronous code call an asynchronous function that runs in a worker thread: the caller blocks until the worker’s promise settles or a timeout expires. That is what a synchronous load hook needs to fetch modules from a URL, for example.

First, the worker exposes the asynchronous work:

// fetch-worker.js
import { wire } from 'everysync';
import { workerData } from 'node:worker_threads';

wire(workerData.buffer, {
  async fetchText(url) {
    const response = await fetch(url);
    return await response.text();
  },
});

// Keep the worker's event loop alive.
setInterval(() => {}, 100000);

Then the registration file starts the worker and calls it from the hooks as if it were synchronous:

// register.js
import { registerHooks } from 'node:module';
import { Worker } from 'node:worker_threads';
import { makeSync } from 'everysync';

const buffer = new SharedArrayBuffer(1024, {
  maxByteLength: 64 * 1024 * 1024,
});
const worker = new Worker(new URL('./fetch-worker.js', import.meta.url), {
  workerData: { buffer },
  execArgv: [], // Don't re-run this file (via --import) inside the worker.
});
worker.unref(); // Don't let the worker keep the process alive.
// The default timeout is 1 second per call, so allow longer for the network.
const { fetchText } = makeSync(buffer, { timeout: 30_000 });

registerHooks({
  resolve(specifier, context, nextResolve) {
    if (specifier.startsWith('https://')) {
      return { url: specifier, shortCircuit: true };
    }
    return nextResolve(specifier, context);
  },
  load(url, context, nextLoad) {
    if (url.startsWith('https://')) {
      return { format: 'module', source: fetchText(url), shortCircuit: true };
    }
    return nextLoad(url, context);
  },
});

Two details matter. Pass execArgv: [] when you preload the registration file with --import, because workers inherit your flags and would otherwise run register.js themselves and start another worker (makeSync() then fails with “The initialization timed out”). And remember that the calling thread is blocked while the worker waits on the network, so this suits tooling and development more than hot paths. everysync gives up after one second by default (The response timed out after 1000ms), so choose a timeout that suits your slowest request.

Earlier versions of Node.js had another way to register hooks: module.register(), along with the --experimental-loader and --loader flags. Those ran asynchronous hooks on a separate loader thread. Node.js 27 removes them (DEP0205); they were deprecated in the documentation in Node.js 24.15.0 and 25.9.0, and began printing a runtime warning in Node.js 26.0.0.

If you have code that still uses them, the module-register-to-register-hooks codemod, one of the userland migrations, rewrites the common cases for you. Hooks that do asynchronous work can use the approaches in Asynchronous work instead.

If your package has to support versions of Node.js with and without registerHooks(), feature-detect it. Check for registerHooks() first, then for module.register() (which only exists in older versions), and handle versions that have neither:

import module from 'node:module';

if (typeof module.registerHooks === 'function') {
  module.registerHooks({ resolve, load });
} else if (typeof module.register === 'function') {
  module.register('./hooks.js', import.meta.url);
} else {
  // This version of Node.js doesn't support customization hooks at all.
  // Skip them, as here, or throw an error if your package can't work without them.
  console.warn(
    'Module customization hooks are not supported by this version of Node.js.'
  );
}