An Idiosyncratic Blog

πŸ›  Customize CSS Loader options in Next.js

Published on
β€’ 6 minutes read

Recently when working on a project migrating the code from Gatsby to Next.js, I faced a very interesting problem.

Next.js supports CSS Modules using the [name].module.css file naming convention. Really cool stuff. I can now import class names using JS syntax like import styles from './styles' and use object notations to reference those styles.

The problem however, Gatsby code had styles referencing HTML tags like a and section.

section {
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 480px;
  margin: 20px 0;
  scroll-margin-top: 74px;
  &.full-width {
    width: 100%;
    margin: 20px auto;
  }
}

This unfortunately doesn't work with the opinions of the creators of Next.js. And there isn't an easy way to override the config of css-loader plugin using next.config.js12. I was not in the mood to update the CSS files, (honestly, there were a lot of them).

So after browsing the internet for a couple of hours, I found a hack3, which works well for my particular use case.

/**
 * Stolen from https://stackoverflow.com/questions/10776600/testing-for-equality-of-regular-expressions
 */
const regexEqual = (x, y) => {
  return (
    x instanceof RegExp &&
    y instanceof RegExp &&
    x.source === y.source &&
    x.global === y.global &&
    x.ignoreCase === y.ignoreCase &&
    x.multiline === y.multiline
  )
}

// Overrides for css-loader plugin
function cssLoaderOptions(modules) {
  const { getLocalIdent, ...others } = modules // Need to delete getLocalIdent else localIdentName doesn't work
  return {
    ...others,
    localIdentName: '[hash:base64:6]',
    exportLocalsConvention: 'camelCaseOnly',
    mode: 'local',
  }
}

module.exports = {
  webpack: (config) => {
    const oneOf = config.module.rules.find((rule) => typeof rule.oneOf === 'object')

    if (oneOf) {
      // Find the module which targets *.scss|*.sass files
      const moduleSassRule = oneOf.oneOf.find((rule) =>
        regexEqual(rule.test, /\.module\.(scss|sass)$/)
      )

      if (moduleSassRule) {
        // Get the config object for css-loader plugin
        const cssLoader = moduleSassRule.use.find(({ loader }) => loader.includes('css-loader'))
        if (cssLoader) {
          cssLoader.options = {
            ...cssLoader.options,
            modules: cssLoaderOptions(cssLoader.options.modules),
          }
        }
      }
    }

    return config
  },
}

cssLoaderOptions method returns the css-loader options which I want to override. L#36, L#42 and L#47 is where the magic happens. Looping through all the modules Next.js uses, we find using a regex module which targets *.module.scss|*.module.sass files, and get the object for css-loader plugin in L#41-42.

If you want to target *.css files instead, change the regex on L#36.

And et voilΓ , problem solved! I do hope the RFC2 moves forward, and we get a built-in option from Next.js to override the config, but until then, this hack remains.


Update on Next.js 12 and above.

The above implementation breaks, because in this PR, the Next.js team has switched to a custom fixed version of css-loader that has no defaultGetLocalIdent, nor does it check if getLocalIndent is null or undefined.

Therefore, removing getLocalIdent from the cssLoaderOptions configuration will lead to TypeError: getLocalIdent is not a function thrown with the above implementation. It has now become necessary to provide a function to getLocalIdent.

Below is the updated implementation for cssLoaderOptions from the above config:

const loaderUtils = require('loader-utils')

/**
 * Generate context-aware class names when developing locally
 */
const localIdent = (loaderContext, localIdentName, localName, options) => {
  return (
    loaderUtils
      .interpolateName(loaderContext, `[folder]_[name]__${localName}`, options)
      // Webpack name interpolation returns `about_about.module__root` for
      // `.root {}` inside a file named `about/about.module.css`. Let's simplify
      // this.
      .replace(/\.module_/, '_')
      // Replace invalid symbols with underscores instead of escaping
      // https://mathiasbynens.be/notes/css-escapes#identifiers-strings
      .replace(/[^a-zA-Z0-9-_]/g, '_')
      // "they cannot start with a digit, two hyphens, or a hyphen followed by a digit [sic]"
      // https://www.w3.org/TR/CSS21/syndata.html#characters
      .replace(/^(\d|--|-\d)/, '__$1')
  )
}

// Overrides for css-loader plugin
function cssLoaderOptions(modules) {
  const { getLocalIdent, ...others } = modules
  return {
    ...others,
    getLocalIdent: isProd ? getLocalIdent : localIdent,
    exportLocalsConvention: 'camelCaseOnly',
    mode: 'local',
  }
}

I use the localIdent to keep the classNames readable when developing locally. When building a production version, I defer to the default implementation of generating the classNames. The rest of the config remains the same as before.

The newer implementation is again a hack, and it works for my use-case. YMMV. Until Next.js provides an official way to override the config, I will continue to use this hack.

Footnotes

  1. Allow users to configure options of css-loader ↩

  2. RFC: cssLoaderOption ↩ ↩2

  3. https://github.com/vercel/next.js/discussions/15818#discussioncomment-121478 ↩