diff --git a/doc/api/errors.md b/doc/api/errors.md
index c589fcd375136d..cb10d48f2dd9ec 100644
--- a/doc/api/errors.md
+++ b/doc/api/errors.md
@@ -1234,6 +1234,23 @@ provided.
Encoding provided to `TextDecoder()` API was not one of the
[WHATWG Supported Encodings][].
+
+
+### `ERR_ESM_LOADER_REGISTRATION_UNAVAILABLE`
+
+
+
+Programmatically registering custom ESM loaders
+currently requires at least one custom loader to have been
+registered via the `--experimental-loader` flag. A no-op
+loader registered via CLI is sufficient
+(for example: `--experimental-loader data:text/javascript,`;
+do not omit the necessary trailing comma).
+A future version of Node.js will support the programmatic
+registration of loaders without needing to also use the flag.
+
### `ERR_EVAL_ESM_CANNOT_PRINT`
diff --git a/doc/api/esm.md b/doc/api/esm.md
index ad03dec7ba5f03..3c32625bda0af2 100644
--- a/doc/api/esm.md
+++ b/doc/api/esm.md
@@ -1236,6 +1236,17 @@ console.log('some module!');
If you run `node --experimental-loader ./import-map-loader.js main.js`
the output will be `some module!`.
+### Register loaders programmatically
+
+
+
+In addition to using the `--experimental-loader` option in the CLI,
+loaders can also be registered programmatically. You can find
+detailed information about this process in the documentation page
+for [`module.register()`][].
+
## Resolution and loading algorithm
### Features
@@ -1632,6 +1643,7 @@ success!
[`import.meta.url`]: #importmetaurl
[`import`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import
[`module.createRequire()`]: module.md#modulecreaterequirefilename
+[`module.register()`]: module.md#moduleregister
[`module.syncBuiltinESMExports()`]: module.md#modulesyncbuiltinesmexports
[`package.json`]: packages.md#nodejs-packagejson-field-definitions
[`port.ref()`]: https://nodejs.org/dist/latest-v17.x/docs/api/worker_threads.html#portref
diff --git a/doc/api/module.md b/doc/api/module.md
index cb0b27cb612be7..2fb1266df48b68 100644
--- a/doc/api/module.md
+++ b/doc/api/module.md
@@ -78,6 +78,101 @@ isBuiltin('fs'); // true
isBuiltin('wss'); // false
```
+### `module.register()`
+
+
+
+In addition to using the `--experimental-loader` option in the CLI,
+loaders can be registered programmatically using the
+`module.register()` method.
+
+```mjs
+import { register } from 'node:module';
+
+register('http-to-https', import.meta.url);
+
+// Because this is a dynamic `import()`, the `http-to-https` hooks will run
+// before importing `./my-app.mjs`.
+await import('./my-app.mjs');
+```
+
+In the example above, we are registering the `http-to-https` loader,
+but it will only be available for subsequently imported modules—in
+this case, `my-app.mjs`. If the `await import('./my-app.mjs')` had
+instead been a static `import './my-app.mjs'`, _the app would already
+have been loaded_ before the `http-to-https` hooks were
+registered. This is part of the design of ES modules, where static
+imports are evaluated from the leaves of the tree first back to the
+trunk. There can be static imports _within_ `my-app.mjs`, which
+will not be evaluated until `my-app.mjs` is when it's dynamically
+imported.
+
+The `--experimental-loader` flag of the CLI can be used together
+with the `register` function; the loaders registered with the
+function will follow the same evaluation chain of loaders registered
+within the CLI:
+
+```console
+node \
+ --experimental-loader unpkg \
+ --experimental-loader http-to-https \
+ --experimental-loader cache-buster \
+ entrypoint.mjs
+```
+
+```mjs
+// entrypoint.mjs
+import { URL } from 'node:url';
+import { register } from 'node:module';
+
+const loaderURL = new URL('./my-programmatically-loader.mjs', import.meta.url);
+
+register(loaderURL);
+await import('./my-app.mjs');
+```
+
+The `my-programmatic-loader.mjs` can leverage `unpkg`,
+`http-to-https`, and `cache-buster` loaders.
+
+It's also possible to use `register` more than once:
+
+```mjs
+// entrypoint.mjs
+import { URL } from 'node:url';
+import { register } from 'node:module';
+
+register(new URL('./first-loader.mjs', import.meta.url));
+register('./second-loader.mjs', import.meta.url);
+await import('./my-app.mjs');
+```
+
+Both loaders (`first-loader.mjs` and `second-loader.mjs`) can use
+all the resources provided by the loaders registered in the CLI. But
+remember that they will only be available in the next imported
+module (`my-app.mjs`). The evaluation order of the hooks when
+importing `my-app.mjs` and consecutive modules in the example above
+will be:
+
+```console
+resolve: second-loader.mjs
+resolve: first-loader.mjs
+resolve: cache-buster
+resolve: http-to-https
+resolve: unpkg
+load: second-loader.mjs
+load: first-loader.mjs
+load: cache-buster
+load: http-to-https
+load: unpkg
+globalPreload: second-loader.mjs
+globalPreload: first-loader.mjs
+globalPreload: cache-buster
+globalPreload: http-to-https
+globalPreload: unpkg
+```
+
### `module.syncBuiltinESMExports()`