If you are a front-end or a fullstack developer, you maybe already imported CSS files from a Javascript or a Typescript file. For instance:

import styles from "./styles.css"

A few months ago it would not have been possible out of the shelf. Indeed you would need a bundler (such as WebPack, Rollup,…) to “inline” the CSS file as a string in your Javascript file.

Nowadays it’s possible thanks to the “CSS Modules” (to not confuse with the homonym open-source project). It’s even already implemented in Chrome. For more details, please read the “CSS Modules (The Native Ones)” article from Chris Coyier. However it’s not yet brought to NodeJS implementation. In the meantime, NodeJS allow us to customize the default module resolution through three JS hooks: resolve, load and globalPreload. These hooks are provided via the command line argument --experimental-loader. Pay attention that it’s an experimental feature. Furthermore the “API is currently being redesigned and will still change”.

We will use this feature to load CSS file as an ECMAScript module and therefore bypass the bundling/building phase. We will also have to use ECMAScript modules to make it work:

To load an ES module, set “type”: “module” in the package.json or use the .mjs extension.

Our project starts with the following files:

/* index.mjs */
import styles from "./styles.css";

console.log(styles);

and

/* styles.css */
html, body {
    margin: 0;
}

Without custom loader, the result would be an “Unknown file extension”.

$ node index.mjs
node:internal/errors:464
    ErrorCaptureStackTrace(err);
    ^

TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".css" for /(...)/styles.css
    at new NodeError (node:internal/errors:371:5)
    (...)
    at async ModuleWrap.<anonymous> (node:internal/modules/esm/module_job:81:21) {
  code: 'ERR_UNKNOWN_FILE_EXTENSION'
}

Node.js v17.3.0

Let’s write our basic custom loader:

/* loader.mjs */
import { URL } from "url";
import { readFile } from "fs/promises";

/**
 * This function loads the content of files ending with ".css" to an ECMAScript Module
 * so the default export is a string containing the CSS stylesheet.
 */     
export async function load(url, context, defaultLoad) {
    if (url.endsWith(".css")) {
        const content = await readFile(new URL(url));

        return {
            format: "module",
            source: `export default ${JSON.stringify(content.toString())};`,
        }
    }

    return defaultLoad(url, context, defaultLoad);
}

Running NodeJS with our loader will now print1 the content of styles.css:

$ node --no-warnings --experimental-loader ./loader.mjs index.mjs
html, body {
    margin: 0;
}

This is a really basic example to understand NodeJS custom loaders. As you may have noticed, I didn’t use “Import Assertions” although it’s mandatory for JSON modules in NodeJS. For security reasons the import should actually look like:

import styles from "./styles.css" assert { type: "css" };
  1. The argument --no-warnings is used for ease of reading, I recommend you to keep warnings in your development and deployments.