Back to the blog
Webpack· Jul 8, 2026· 8 min read

Writing a custom Webpack loader from scratch

Loaders are just functions that transform source text. We build a real one - a loader that inlines SVGs as React components - and cover the pitfalls around caching, source maps, and async loaders.

#webpack#loaders#build-tools#javascript

A Webpack loader is conceptually smaller than it sounds: it's a function that receives a file's source as a string and returns a transformed string (or, for binary content, a Buffer). Every `.tsx`, `.css`, and `.svg` you import goes through a chain of these. Writing one demystifies a lot of what Webpack 'does' under the hood.

The minimum viable loader

// loaders/svg-to-component-loader.js
module.exports = function svgToComponentLoader(source) {
  const jsx = source
    .replace('<svg', '<svg {...props}')
    .replace(/xmlns:.*?=".*?"/g, '');

  return [
    "import * as React from 'react';",
    'export default function SvgIcon(props) {',
    '  return ' + JSON.stringify(jsx) + ';',
    '}',
  ].join('\n');
};

That snippet is intentionally rough - real SVG-to-JSX transformation needs an actual parser (that's what `@svgr/webpack` does under the hood) - but the shape is the whole point: a loader is `(source: string) => string`, registered against a file test, and Webpack pipes matching files through it before bundling.

Wiring it into a config

module.exports = {
  module: {
    rules: [
      {
        test: /\.svg$/,
        use: [
          { loader: '@babel/preset-react is applied by babel-loader after this' },
          path.resolve(__dirname, 'loaders/svg-to-component-loader.js'),
        ],
      },
    ],
  },
};

The pitfall: loaders run right-to-left, bottom-to-top

In a `use` array, loaders execute last-to-first. This trips up nearly everyone the first time: `use: ['style-loader', 'css-loader']` runs css-loader FIRST (turning CSS into a JS module) and style-loader SECOND (injecting that JS module's output into a <style> tag). Get the order backwards and you get a cryptic 'unexpected token' error that has nothing to do with your actual CSS.

Async loaders and the `this.async()` API

module.exports = function asyncLoader(source) {
  const callback = this.async();
  someAsyncTransform(source)
    .then((result) => callback(null, result))
    .catch((err) => callback(err));
};

A loader that needs to do I/O - fetch a remote schema, call an external formatter binary - can't just `return` a Promise; Webpack expects a synchronous return unless you explicitly opt into async mode via `this.async()`, which hands back a Node-style callback. Forgetting this is the most common cause of a loader silently producing `undefined` output.

Caching and source maps: don't skip these

  • Set `this.cacheable()` (default true in most Webpack 5 setups, but verify) so unchanged files don't re-run your loader on every rebuild - expensive loaders without caching will visibly slow incremental builds.
  • If your transform changes line numbers, generate and return a source map via the loader's third callback argument - otherwise stack traces and devtools breakpoints point at the wrong line in the transformed output.
  • Use `this.query` / loader options schema validation (`schema-utils`) so a typo'd option fails loudly at build time instead of silently doing nothing.

Written by Appesto Engineering.