Writing a Loader
A loader is a node module that exports a function. This function is called when a resource should be transformed by this loader. The given function will have access to the Loader API using the this context provided to it.
Setup
Before we dig into the different types of loaders, their usage, and examples, let's take a look at the three ways you can develop and test a loader locally.
To test a single loader, you can use path to resolve a local file within a rule object:
webpack.config.js
import path from "node:path";
export default {
// ...
module: {
rules: [
{
test: /\.js$/,
use: [
{
loader: path.resolve("path/to/loader.js"),
options: {/* ... */},
},
],
},
],
},
};To test multiple, you can utilize the resolveLoader.modules configuration to update where webpack will search for loaders. For example, if you had a local /loaders directory in your project:
webpack.config.js
import path from "node:path";
const __dirname = import.meta.dirname;
export default {
// ...
resolveLoader: {
modules: ["node_modules", path.resolve(__dirname, "loaders")],
},
};By the way, if you've already created a separate repository and package for your loader, you could npm link it to the project in which you'd like to test it out.
Changes to a loader need a restart
This catches most people developing a loader against a demo project, and neither half of it fails loudly:
- The edit does not trigger a rebuild. webpack records the loader's path in the module's
buildDependencies, which invalidate the persistent cache on the next start — the watcher only watches file, context and missing dependencies, so nothing rebuilds when the loader file changes. - Forcing a rebuild does not help either. webpack loads a loader with
require()(orimport()for an ESM loader) once, and Node caches that module for the life of the process. A module rebuilt after the edit is handed the old loader function and produces the old output, with no error and no warning.
So calling this.addDependency(__filename) in your loader is not a workaround. It does what it says — the loader file is now watched, and editing it rebuilds the modules that use it — but that rebuild still runs the loader code the process loaded at startup, which is worse than not rebuilding at all: the build reports work it did not really redo.
Develop the loader against its own tests instead, where each run is a fresh process (see Testing), and restart webpack when you want to see a change in the demo — nodemon --watch loaders -- node_modules/.bin/webpack serve or an equivalent wrapper makes that automatic.
Simple Usage
When a single loader is applied to the resource, the loader is called with only one parameter – a string containing the content of the resource file.
Synchronous loaders can return a single value representing the transformed module. In more complex cases, the loader can return any number of values by using the this.callback(err, values...) function. Errors are either passed to the this.callback function or thrown in a sync loader.
The loader is expected to give back one or two values. The first value is a resulting JavaScript code as string or buffer. The second optional value is a SourceMap as JavaScript object.
Complex Usage
When multiple loaders are chained, it is important to remember that they are executed in reverse order – either right to left or bottom to top depending on array format.
- The last loader, called first, will be passed the contents of the raw resource.
- The first loader, called last, is expected to return JavaScript and an optional source map.
- The loaders in between will be executed with the result(s) of the previous loader in the chain.
In the following example, the foo-loader would be passed the raw resource and the bar-loader would receive the output of the foo-loader and return the final transformed module and a source map if necessary.
webpack.config.js
export default {
// ...
module: {
rules: [
{
test: /\.js/,
use: ["bar-loader", "foo-loader"],
},
],
},
};Pitching loaders
Loaders normally run right to left. A loader can also export a
pitch function, which runs left to right before the normal phase
begins. This allows a loader to pass data to its own normal phase or
short-circuit the remaining loader chain.
// my-loader.js
export default function (source) {
// Normal phase — runs right to left
const prefix = this.data.value ?? "";
return `${prefix}\n${source}`;
}
export function pitch(remainingRequest, precedingRequest, data) {
// Pitch phase — runs left to right before normal loaders
data.value = "/* processed by my-loader */";
}Short-circuiting the loader chain
If a pitch function returns a value, webpack skips the remaining
loaders to the right and reverses immediately. This can be useful when
you want to generate module code early and bypass the normal phase.
export function pitch(remainingRequest) {
return `
import style from ${JSON.stringify(`!!${remainingRequest}`)};
const el = document.createElement("style");
el.textContent = style;
document.head.appendChild(el);
`;
}Guidelines
The following guidelines should be followed when writing a loader. They are ordered in terms of importance and some only apply in certain scenarios, read the detailed sections that follow for more information.
- Keep them simple.
- Utilize chaining.
- Emit modular output.
- Make sure they're stateless.
- Employ loader utilities.
- Mark loader dependencies.
- Resolve module dependencies.
- Extract common code.
- Avoid absolute paths.
- Use peer dependencies.
Simple
Loaders should do only a single task. This not only makes the job of maintaining each loader easier, but also allows them to be chained for usage in more scenarios.
Chaining
Take advantage of the fact that loaders can be chained together. Instead of writing a single loader that tackles five tasks, write five simpler loaders that divide this effort. Isolating them not only keeps each individual loader simple, but may allow for them to be used for something you hadn't thought of originally.
Take the case of rendering a template file with data specified via loader options or query parameters. It could be written as a single loader that compiles the template from source, executes it and returns a module that exports a string containing the HTML code. However, in accordance with guidelines, an apply-loader exists that can be chained with other open source loaders:
pug-loader: Convert template to a module that exports a function.apply-loader: Executes the function with loader options and returns raw HTML.
Webpack takes it from there: the HTML that falls out of the chain is handled by built-in HTML support, so nothing needs to turn it into a module.
Modular
Keep the output modular. Loader generated modules should respect the same design principles as normal modules.
Stateless
Make sure the loader does not retain state between module transformations. Each run should always be independent of other compiled modules as well as previous compilations of the same module.
Loader Utilities
Take advantage of the loader-utils package which provides a variety of useful tools. Along with loader-utils, the schema-utils package should be used for consistent JSON Schema based validation of loader options. Here's a brief example that utilizes both:
loader.js
import { urlToRequest } from "loader-utils";
import { validate } from "schema-utils";
const schema = {
type: "object",
properties: {
test: {
type: "string",
},
},
};
export default function (source) {
const options = this.getOptions();
validate(schema, options, {
name: "Example Loader",
baseDataPath: "options",
});
console.log("The request path", urlToRequest(this.resourcePath));
// Apply some transformations to the source...
return `export default ${JSON.stringify(source)}`;
}Data Sharing
In webpack, loaders can be chained together and share data with subsequent loaders in the chain. To achieve this, you can pass data along with the content (source code) using the this.callback method. In the default exported function of a loader, you can pass data using the fourth argument of this.callback.
export default function (source) {
const options = this.getOptions();
// Pass data using the fourth argument of this.callback
this.callback(null, `export default ${JSON.stringify(source)}`, null, {
some: data,
});
}In the example above, some property in the fourth argument of this.callback is used to pass data to the next chained loader.
Sharing data with a plugin
A loader can only return module source, so a loader that extracts something else — style fragments to concatenate, icons to pack into a sprite — needs another way to hand that to the plugin which emits the combined asset. Store it on the module's buildInfo:
loader.js
export default function (source) {
this._module.buildInfo.myLoaderData = collectIcons(source);
return source;
}The plugin then reads it back off the modules of the compilation:
plugin.js
compiler.hooks.thisCompilation.tap("MyPlugin", (compilation) => {
compilation.hooks.processAssets.tap(
{
name: "MyPlugin",
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
},
() => {
const collected = [];
for (const module of compilation.modules) {
const data = module.buildInfo.myLoaderData;
if (data) collected.push(...data);
}
compilation.emitAsset(
"sprite.svg",
new compiler.webpack.sources.RawSource(buildSprite(collected)),
);
},
);
});buildInfo is serialized together with the module, so the data survives the persistent cache and is still there on a build where the loader itself never runs again. That is the reason to use it rather than a variable in the loader's own module scope, which a cached build leaves empty. Only store plain, serializable values there, and namespace the property so two loaders don't collide.
Loader Dependencies
If a loader uses external resources (i.e. by reading from filesystem), they must indicate it. This information is used to invalidate cacheable loaders and recompile in watch mode. Here's a brief example of how to accomplish this using the addDependency method:
loader.js
import path from "node:path";
export default function (source) {
const callback = this.async();
const headerPath = path.resolve("header.js");
this.addDependency(headerPath);
fs.readFile(headerPath, "utf8", (err, header) => {
if (err) return callback(err);
callback(null, `${header}\n${source}`);
});
}Module Dependencies
Depending on the type of module, there may be a different schema used to specify dependencies. In CSS for example, the @import and url(...) statements are used. These dependencies should be resolved by the module system.
This can be done in one of two ways:
- By transforming them to
requirestatements. - Using the
this.resolvefunction to resolve the path.
A CSS loader is a good example of the first approach: it transforms dependencies to requires, replacing @import statements with a require to the other stylesheet and url(...) with a require to the referenced file. Webpack now does this itself — see Native CSS.
In the case of the less-loader, it cannot transform each @import to a require because all .less files must be compiled in one pass for variables and mixin tracking. Therefore, the less-loader extends the less compiler with custom path resolving logic. It then takes advantage of the second approach, this.resolve, to resolve the dependency through webpack.
Hot Module Replacement
A loader takes part in HMR through the code it generates: webpack replaces a changed module at runtime, and the generated module decides what to do with the new version by calling module.hot.accept. A loader that produces a side effect — installing a stylesheet, registering a template, seeding a store — has to emit that call itself, or an edit to one of its files will reload the whole page instead of being applied in place.
this.hot tells you whether that is worth emitting: it is true only when HotModuleReplacementPlugin is applied, so guard the extra code with it and keep production output clean.
export default function (source) {
const installed = `install(${JSON.stringify(transform(source))});`;
if (!this.hot) return installed;
return `${installed}
if (import.meta.webpackHot) {
import.meta.webpackHot.accept();
}`;
}Accepting in the generated module — rather than leaving it to whoever imports it — is what keeps an update from bubbling up to the entry point and forcing a full reload.
Prefer import.meta.webpackHot over module.hot in generated code: webpack recognizes it whether the module ends up auto-detected or strict ESM, while module.hot is unavailable in a strict ESM module — it compiles to a reference to an undefined module, which builds without an error and throws at runtime. A loader does not control which of the two its output becomes, since that follows from the file it processes.
Common Code
- Avoid generating common code in every module the loader processes. Instead, create a runtime file in the loader and
import(orrequire) it as a shared module:
src/loader-runtime.js
import { someOtherModule } from "./some-other-module.js";
export default function runtime(params) {
const x = params.y * 2;
return someOtherModule(params, x);
}src/loader.js
import runtime from "./loader-runtime.js";
export default function loader(source) {
// Custom loader logic
return `${runtime({
source,
y: Math.random(),
})}`;
}Absolute Paths
Don't insert absolute paths into the module code as they break hashing when the root for the project is moved. You can use below code to convert absolute paths to relative ones.
// `loaderContext` is same as `this` inside loader function
JSON.stringify(
loaderContext.utils.contextify(
loaderContext.context || loaderContext.rootContext,
request,
),
);Peer Dependencies
If the loader you're working on is a simple wrapper around another package, then you should include the package as a peerDependency. This approach allows the application's developer to specify the exact version in the package.json if desired.
For instance, the sass-loader specifies node-sass as peer dependency like so:
{
"peerDependencies": {
"node-sass": "^4.0.0"
}
}Testing
So you've written a loader, followed the guidelines above, and have it set up to run locally. What's next? Let's go through a unit testing example to ensure our loader is working the way we expect. We'll be using the Jest framework to do this. We'll also install babel-jest and some presets that will allow us to use the import / export and async / await. Let's start by installing and saving these as a devDependencies:
npm install --save-dev jest babel-jest @babel/core @babel/preset-envbabel.config.js
export default {
presets: [
[
"@babel/preset-env",
{
targets: {
node: "current",
},
},
],
],
};Our loader will process .txt files and replace any instance of [name] with the name option given to the loader. Then it will output a valid JavaScript module containing the text as its default export:
src/loader.js
export default function loader(source) {
const options = this.getOptions();
source = source.replaceAll("[name]", options.name);
return `export default ${JSON.stringify(source)}`;
}We'll use this loader to process the following file:
test/example.txt
Hey [name]!Pay close attention to this next step as we'll be using the Node.js API and memfs to execute webpack. This lets us avoid emitting output to disk and will give us access to the stats data which we can use to grab our transformed module:
npm install --save-dev webpack memfstest/compiler.js
import path from "node:path";
import { Volume, createFsFromVolume } from "memfs";
import webpack from "webpack";
const __dirname = import.meta.dirname;
export default (fixture, options = {}) => {
const compiler = webpack({
context: __dirname,
entry: `./${fixture}`,
output: {
path: path.resolve(__dirname),
filename: "bundle.js",
},
module: {
rules: [
{
test: /\.txt$/,
use: {
loader: path.resolve(__dirname, "../src/loader.js"),
options,
},
},
],
},
});
compiler.outputFileSystem = createFsFromVolume(new Volume());
compiler.outputFileSystem.join = path.join.bind(path);
return new Promise((resolve, reject) => {
compiler.run((err, stats) => {
if (err) reject(err);
if (stats.hasErrors()) reject(stats.toJson().errors);
resolve(stats);
});
});
};And now, finally, we can write our test and add an npm script to run it:
test/loader.test.js
/**
* @jest-environment node
*/
import compiler from "./compiler.js";
test("Inserts name and outputs JavaScript", async () => {
const stats = await compiler("example.txt", { name: "Alice" });
const output = stats.toJson({ source: true }).modules[0].source;
expect(output).toBe('export default "Hey Alice!\\n"');
});package.json
{
"scripts": {
"test": "jest"
},
"jest": {
"testEnvironment": "node"
}
}With everything in place, we can run it and see if our new loader passes the test:
PASS test/loader.test.js
✓ Inserts name and outputs JavaScript (229ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 1.853s, estimated 2s
Ran all test suites.It worked! At this point you should be ready to start developing, testing, and deploying your own loaders. We hope that you'll share your creations with the rest of the community!



