Critical CSS with NextJS

Sergey Labut - May 31 - - Dev Community

In this article, we’ll share our experience with Google's CSS tool for extracting critical CSS – Critters. While still experimental, we've successfully used this tool in production and found it significantly improves the performance of static sites. When properly configured, it can potentially enhance SSR performance as well. Critters works well with static pages and can handle dynamic pages with some nuances.

All the examples in this article demonstrate a browserless method of extracting critical CSS using Critters. However, it’s worth mentioning tools that use headless browsers. These tools load the page in a specified viewport and determine the necessary styles. This results in a very small amount of critical CSS tailored to render the page in that specific viewport. Typically, multiple viewports can be defined to support both mobile and desktop devices, allowing for flexible customization and easy integration into the build pipeline. However, headless browsers come with drawbacks: they are slow, unreliable, and prone to crashing. While not without merit, these tools don't scale well, are unreliable due to their dependence on headless browsers, and are unsuitable for runtime environments.

Browserless tools, like Critters, avoid these disadvantages. Although the extracted CSS will be larger since it covers the entire page across all device sizes, this increase is usually negligible thanks to minification and compression. In the end, it remains much smaller than the original stylesheet.

Limitations

The best use case for Critters is a static website, such as a news site or blog. However, it's important to determine whether critical CSS optimization will benefit your performance. Simply put, will implementing this optimization result in a performance boost? Sometimes, CSS isn't the main factor causing poor performance, even though CSS in the <head/> blocks rendering. For example, if your Largest Contentful Paint (LCP) is an image that loads much later than the CSS, the CSS won't be blocking the LCP. In such cases, you won't see a performance improvement from CSS optimization. However, this doesn't mean the optimization is useless—other pages might be genuinely render-blocked by CSS, and you'll see performance gains for those.
Additionally, critical CSS optimization should be the final step in HTML editing. After applying this optimization, it's best to use the HTML as-is without further modifications.
To start using Critters, simply install it with yarn add critters. Ensure your pages are linked to the correct stylesheet using a <link/> tag in the page<head/> (modern bundlers typically add these links if there are CSS file imports) or by passing parameters during Critters initialization. Then, create a script for generating critical CSS and integrate it into your build pipeline.

Critical CSS for Static Websites

Okay, let's look at the first simplest case - a completely static site. All pages are rendered at build time. In this case, you simply create the following script and run it at the end of the build pipeline.

Here are the basic steps:

  1. Get all HTML files
  2. Initialize Critters with settings
  3. Read HTML file
  4. Process HTML file with Critters
  5. Parse HTML after Critters
  6. Find all the <link/> tags in the <head/> and remove each <link/>.
  7. Save HTML file

We will use the code from this snippet to get all the HTML files from a folder. (took it from here)

const fs = require("fs");

// Recursive function to get files
function getHTMLFiles(dir, files = []) {
  // Get an array of all files and directories in the passed directory using fs.readdirSync
  const fileList = fs.readdirSync(dir);
  // Create the full path of the file/directory by concatenating the passed directory and file/directory name
  for (const file of fileList) {
    const name = `${dir}/${file}`;
    // Check if the current file/directory is a directory using fs.statSync
    if (fs.statSync(name).isDirectory()) {
      // If it is a directory, recursively call the getFiles function with the directory path and the files array
      getHTMLFiles(name, files);
    } else {
      // If it is an HTML file, push the full path to the files array
      if (name.endsWith("html")) {
        files.push(name);
      }
    }
  }
  return files;
}
Enter fullscreen mode Exit fullscreen mode

critcalcss.js module

const fs = require("fs");
const Critters = require("critters");
const { join } = require("path");
const { parse } = require("node-html-parser");

async function criticalCSS() {
  const currentFolder = join(process.cwd(), "build");
  const files = getHTMLFiles(currentFolder);

  for (const file of files) {
      const critters = new Critters({ path: currentFolder });
      const html = fs.readFileSync(file, "utf-8");
      const inlined = await critters.process(html);
      // additional step: delete links in the <head/> that left after critters
      const DOMAfterCritters = parse(inlined);
      const head = DOMAfterCritters.querySelector("head");

      for (const linkInHead of head.querySelectorAll("link")) {
        if (
          linkInHead.attributes?.as === "style" ||
          linkInHead.attributes?.rel === "stylesheet"
        ) {
          linkInHead.remove();
        }
      }

      fs.writeFileSync(file, DOMAfterCritters.toString());
    }  
}

criticalCSS();
Enter fullscreen mode Exit fullscreen mode

Note: Critters should postpone your styles – move from <head/> to <body/> or wrap it with <noscript/> fallback. If there are still <link/> in <head/> you can add an additional step to handle that.

Critical CSS for Static Websites With CSS-in-JS

If you are using any CSS-in-JS library, you usually don't have a CSS file. No worries, we can create it during optimization, save it and add <link/> with this CSS to the html file.

Here are the basic steps:

  1. Get all HTML files
  2. Initialize Critters with settings
  3. Read html file
  4. Parse HTML
  5. Read existing styles from <style/> tags in <head/> and store each one in a Set.
  6. Combine styles in one string and create a hash and path for the resulting styles
  7. Save the CSS file
  8. Process HTML with Critters
  9. Parse HTML after Critters
  10. Add style sheets to <body/> for lazy loading
  11. Save HTML file
const fs = require("fs");
const Critters = require("critters");
const { parse } = require("node-html-parser");
const CryptoJS = require("crypto-js");
const { join } = require("path");
const { minify } = require("csso");

async function criticalCSS() {
  const currentFolder = join(process.cwd(), "build");
  const files = getHTMLFiles(currentFolder);

  for (const file of files) {
    const critters = new Critters({ path: currentFolder });
    const html = fs.readFileSync(file, "utf-8");
    const DOMBeforeCritters = parse(html);
    const uniqueImportantStyles = new Set();

    // first find all inline styles and add them to Set
    for (const style of DOMBeforeCritters.querySelectorAll("style")) {
      uniqueImportantStyles.add(style.innerHTML);
    }

    const inlined = await critters.process(html);
    const importantCSS = Array.from(uniqueImportantStyles).join("");
    const DOMAfterCritters = parse(inlined);
    const body = DOMAfterCritters.querySelector("body");

    // if there was inline styles before Critters
    if (importantCSS.length > 0) {
      const hash = CryptoJS.MD5(CryptoJS.enc.Latin1.parse(importantCSS));
      const inlinedStylesPath = `/static/css/styles.${hash}.css`;

      fs.writeFileSync(
        join(currentFolder, inlinedStylesPath),
        // minification is optional here if you have gzip enabled
        minify(importantCSS).css
      );

      if (body) {
        // we should add <link/> with stylesheet
        body.insertAdjacentHTML(
          "beforeend",
          `<link rel="stylesheet" href="${inlinedStylesPath}" />`
        );
      }
    }

    fs.writeFileSync(file, DOMAfterCritters.toString());
  }
}

criticalCSS();
Enter fullscreen mode Exit fullscreen mode

Fully Static With Both: Regular CSS and CSS-in-JS

We can also imagine a case where we use CSS at the same time as CSS-in-JS. Doubtful, but ok.

Here are the basic steps:

  1. Get all HTML files
  2. Initialize Critters with settings
  3. Read html file
  4. Parse HTML
  5. Read existing styles from <style/> tags in <head/> and store each one in a Set.
  6. Process HTML with Critters
  7. Parse HTML after Critters
  8. Get all <link/> stylesheets from all HTML, keep the href and remove the <link/>.
  9. Read all the styles from the stylesheets, pass them through Set and combine them into one
  10. Combine the styles from step 5 with the file from the previous step
  11. Create a hash and path for the resulting styles
  12. Save the CSS file
  13. Add style sheets to <body/> for lazy loading
  14. Save HTML file
const Critters = require("critters");
const { join } = require("path");
const fs = require("fs");
const { parse } = require("node-html-parser");
const CryptoJS = require("crypto-js");
const { minify } = require("csso");

async function criticalCSS() {
  const currentFolder = join(process.cwd(), ".next");
  const files = getHTMLFiles(currentFolder);
  const critters = new Critters({
    path: currentFolder,
    fonts: true, // inline critical font rules (may be better for performance)
  });
  for (const file of files) {
    try {
      const html = fs.readFileSync(file, "utf-8");
      const DOMBeforeCritters = parse(html);
      const uniqueImportantStyles = new Set();
      // first find all inline styles and add them to Set
      for (const style of DOMBeforeCritters.querySelectorAll("style")) {
        uniqueImportantStyles.add(style.innerHTML);
      }
      const pathPatterns = {
        real: "/static/css",
        original: "/_next/static/css",
      };
      const changedToRealPath = html.replaceAll(
        pathPatterns.original,
        pathPatterns.real
      );
      const inlined = await critters.process(changedToRealPath);
      const DOMAfterCritters = parse(inlined);
      // merge all styles form existing <style/> tags into one string
      const importantCSS = Array.from(uniqueImportantStyles).join("");
      const body = DOMAfterCritters.querySelector("body");
      if (importantCSS.length > 0) {
        const attachedStylesheets = new Set();
        const stylesheets = [];
        // find all `<link/>` tags with styles, get href from them and remove them from HTML
        for (const link of DOMAfterCritters.querySelectorAll("link")) {
          if (
            link.attributes?.as === "style" ||
            link.attributes?.rel === "stylesheet"
          ) {
            attachedStylesheets.add(link.getAttribute("href"));
            link.remove();
          }
        }
        // go through found stylesheets: read file with CSS and push CSS string to stylesheets array
        for (const stylesheet of Array.from(attachedStylesheets)) {
          const stylesheetStyles = fs.readFileSync(
            join(currentFolder, stylesheet)
          );
          stylesheets.push(stylesheetStyles);
        }
        // Merge all stylesheets in one, add importantCSS in the end to persist specificity
        const allInOne = stylesheets.join("") + importantCSS;
        // using the hash, we will only create a new file if a file with that content does not exist
        const hash = CryptoJS.MD5(CryptoJS.enc.Latin1.parse(allInOne));
        const inlinedStylesPath = `/static/css/styles.${hash}.css`;
        fs.writeFileSync(
          join(currentFolder, inlinedStylesPath),
          // minification is optional here, it doesn't affect performance -- it is a lazy loaded CSS stylesheet, it only affects payload
          minify(allInOne).css
        );
        if (body) {
          body.insertAdjacentHTML(
            "beforeend",
            `<link rel="stylesheet" href="/_next${inlinedStylesPath}" />`
          );
        }
      }
      fs.writeFileSync(file, DOMAfterCritters.toString());
    } catch (error) {
      console.log(error);
    }
  }
}
criticalCSS();
Enter fullscreen mode Exit fullscreen mode

Essentially, this is a workaround for preserving the specificity of applied styles in cases where both inline styles and regular CSS are used.

NextJS Pages Router Critical CSS

To read about this, follow the link to the original article.

Critical CSS for Dynamic Pages (SSR) in NextJS Pages Router with Custom Server

To read about this, follow the link to the original article.

Links:

Conclusion

Even though critical css optimization comes with the cost it can greatly increase performance. But first, it's important to measure performance and see if you have performance issues with styles: whether they're actually blocking your metrics like FCP and LCP or not. So it might not be worth it if your LCP is an image and loads much later than the styles. In this case, you will not get a big improvement. On the other hand it doesn't hurt if you can overcome the extra overhead. It's not exactly low hanging fruit, but at least for static builds it's viable.

This approach is not limited to NextJS only. Looks like this critical CSS optimization is suitable for any frameworks that can generate HTML at build time.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .