Skip to main content

Critical CSS with NextJS

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.

Critical CSS with NextJS

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;
}

criticalcss.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();

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();

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();

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 start using critters with NextJS, you need to install it and add one line to next.config.js

const nextConfig = { experimental: { optimizeCss: true } };
module.exports = nextConfig;

We will get both: build time and runtime optimization. The potential downside here is that the critters will run on every request for SSR pages. Also it is not working with the App folder.

To get an idea of what you can get, check out some of the metrics below. Please note that these tests are synthetic and not for the actual production page, but for the slightly modified default nextJS page. But either way, I hope you'll get some thoughts on the usefulness (or otherwise) of critical CSS.

We won't get a performance boost for the default NextJS page with a logo because loading the image will take much longer than loading the CSS, but if we replace the logo with text, the CSS will slow down the LCP.

A custom page where the logo is replaced with text.

NextJS pageNextJS page

Red and Orange – without Critters for desktop and mobile devices respectively. Blue and Green – with Critters for desktop and mobile respectively. The X axis is the test number, starting from zero, the Y axis is the time in milliseconds. The higher, the worse.

Slow 3GSlow 3G

Slow 3G

Fast 3GFast 3G

Fast 3G

Slow 4GSlow 4G

Slow 4G

As you can see, on slow connections you can potentially improve performance with critical CSS.

Effective caching can help reduce the frequency of critters launches. But if we don't have control over the critters itself, we will need to cache the entire page. Another possible solution would be to use a custom server.

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

Note: Because dynamic pages fetch content on request, the page layout can potentially change. If at some point we extracted the critical CSS and then the layout changed - a new element appeared - then we have a potential problem. In such cases it is no longer safe to use these styles as it can cause the layout to shift and flash of unstyled content. One possible solution would be to cache the entire page or styles for a short period of time - for example, 5 minutes.

We will look at the case where we have both static and dynamic pages. In this case we will have runtime optimization and possible additional build time optimization. Optimization at the build step can be one of those described above.

Note: custom server will not work on Vercel.

First, I'll describe the idea in simple terms: on every first visit to the dynamic route, we fallback to the default styles and run critical CSS optimization in the background. The next time the user visits the same dynamic route, we will inline the critical CSS and load the stylesheets asynchronously. The build step, if it exists, will process static pages and also create a file with the processed pages/routes to ignore at runtime. There are no special requirements for optimization at the build step other than those listed above.

However, there is a lot of overhead at runtime: it is better to use a special _document.js file to associate the css with the routes, rather than importing it where it is needed. Why? Nextjs has its own handling of CSS files, and once bound to a page/route, the CSS is difficult to detach. So it's better to inject CSS dynamically, and _document.js is the ideal place since it runs on the server. We will need to add links with stylesheets depending on the route. To serve static CSS files from the NextJS build folder, we will need to copy them to a static folder during the build phase.

Now in detail.

To mark routes that have already been processed, we can collect the routes during the build phase and save them to a file processedRoutes.json.

['/','/about', ...]

critters.js module looks like this.

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

async function processHTMLFile(file, htmlString, runtime) {
try {
const critters = new Critters();
// we don't read file at runtime
const html = htmlString || (file && fs.readFileSync(file, "utf-8"));
// nextJS paths
const pathPatterns = {
real: "/.next/static/css",
original: "/_next/static/css",
};
const changedToRealPath = html.replaceAll(
pathPatterns.original,
pathPatterns.real
);
const inlined = await critters.process(changedToRealPath);
const restoredNextJSPath = inlined.replaceAll(
pathPatterns.real,
pathPatterns.original
);
const DOMAfterCritters = parse(restoredNextJSPath);
const head = DOMAfterCritters.querySelector("head");
if (head) {
// delete links in the `<head/>` that left after critters
for (const linkInHead of head.querySelectorAll("link")) {
if (
linkInHead.attributes?.as === "style" ||
linkInHead.attributes?.rel === "stylesheet"
) {
linkInHead.remove();
}
}
}

// save HTML file in runtime, only for ISR https://nextjs.org/docs/pages/building-your-application/data-fetching/incremental-static-regeneration
if (runtime === "ISR") {
const filePath = join(
process.cwd(),
".next",
"server",
"pages",
file + ".html"
);
fs.writeFile(filePath, DOMAfterCritters.toString(), (err) => {
if (err) {
console.error("Error saving the HTML file:", err);
} else {
console.log("The HTML file has been saved: ", filePath);
}
});
// we don't save file in SSR
} else if (runtime !== "SSR") {
fs.writeFileSync(file, DOMAfterCritters.toString());
}

const inlinedStyles = DOMAfterCritters.querySelector("style");
// return critical css
return inlinedStyles.text;
} catch (error) {}
}
async function main() {
const currentFolder = join(process.cwd(), ".next");
const files = getHTMLFiles(currentFolder);
const processedRoutes = [];
for (const file of files) {
const slug = file.split(".next/server/pages")[1];
await processHTMLFile(file);
processedRoutes.push(slug.replace(".html", "").replace("index", ""));
}
fs.writeFileSync(
join(process.cwd(), "processedRoutes.json"),
JSON.stringify(processedRoutes)
);
}

if (process.env.CRITTERS_BUILD) {
console.time("Critters: build job");
main();
console.timeEnd("Critters: build job");
}

module.exports = { processHTMLFile };

We need to place the server.js file at the same level as next.config.js.

const { createServer } = require("http");
const { parse } = require("url");
const next = require("next");
const fs = require("fs");
const zlib = require("zlib");
const { join } = require("path");
const { processHTMLFile } = require("./critters");
const dev = process.env.NODE_ENV !== "production";
const hostname = dev ? "localhost" : "example.com";
const port = 3000;
const app = next({ dev, port, hostname });
const handle = app.getRequestHandler();
const DIR = "critters";
const processedRoutes = new Set();
const routes = {};
const cachingTime = 5 * 60 * 1000; // 5 min

try {
console.time("Critters: runtime prepare");
// delete the folder if it remains from the previous runtime
fs.rmSync(DIR, { recursive: true, force: true });
// create a folder for critical styles collected at runtime, recreating the nextJS pages structure
fs.cpSync("pages", DIR, {
recursive: true,
overwrite: true,
filter: function (source) {
if (source.includes(".")) {
return false;
}
return true;
},
});
//
const processedHTMLFiles = fs.readFileSync(
join(process.cwd(), "processedRoutes.json"),
"utf-8"
);
// add the processed routes to a Set to be able to check at runtime
JSON.parse(processedHTMLFiles).forEach((file) => processedRoutes.add(file));
console.timeEnd("Critters: runtime prepare");
} catch (error) {}

async function saveStylesToFile(html, path) {
const folder = DIR + path;
const styles = await processHTMLFile(path, html, "SSR");
// folder to mimic routes structure in nextJS
fs.mkdir(folder, { recursive: true }, () => {
const filePath = join(folder, "styles.css");
fs.writeFile(filePath, styles, (err) => {
if (err) {
console.error("Error saving styles to file:", err);
} else {
console.log("styles saved to file:", filePath);
}
});
console.timeEnd("Critters: runtime");
});
}

app.prepare().then(() => {
createServer((req, res) => {
// get current route
const parsedUrl = parse(req.url, true);
const pathname = parsedUrl.pathname;
// if the route was not processed or the cache is stale
if (
!processedRoutes.has(pathname) ||
Date.now() - routes[pathname] > cachingTime
) {
const originalWrite = res.write;
const chunks = [];
res.write = function (chunk) {
if (
res.statusCode === 200 &&
res.getHeader("content-type")?.includes("text/html")
) {
// html is served in chunks, so we need to collect those chunks
chunks.push(chunk);
}
originalWrite.apply(res, arguments);
};
// after we sent html to a user
res.on("finish", () => {
if (
res.statusCode === 200 &&
res.getHeader("content-type")?.includes("text/html")
) {
// add route to processedRoutes
processedRoutes.add(pathname);
// put in task queue
setTimeout(() => {
console.time("Critters: runtime");
// combine all chunks
const html = Buffer.concat(chunks);
// the data is compressed by default, so we need to decompress it to be able to process it
zlib.unzip(html, (err, decompressedData) => {
if (err) {
console.error("Error decompressing data:", err);
return;
}
// start processing html
saveStylesToFile(decompressedData.toString(), pathname);
// update cache time
routes[pathname] = Date.now();
});
}, 0);
}
});
}
handle(req, res, parsedUrl);
}).listen(3000, (err) => {
if (err) throw err;
console.log(`> Ready on http://localhost:${port}`);
});
});

Also we need to modify build and start scripts in package.json.

  "scripts": {
"build": "next build && CRITTERS_BUILD=1 node critters",
"start": "CRITTERS_RUNTIME=1 NODE_ENV=production node server"
}

_document.js file can look like this.

import React from "react";
import { Html, Head, Main, NextScript } from "next/document";
import { join } from "path";
import fs from "fs";
function getCSSPaths(page) {
switch (true) {
case page.startsWith("/dynamic"):
return [
join("/_next", "static", "css", "dynamic.css"),
join("/_next", "static", "css", "global.css"),
];
case page === "/":
return [
join("/_next", "static", "css", "home.css"),
join("/_next", "static", "css", "global.css"),
];
default:
return [join("/_next", "static", "css", "global.css")];
}
}
function getCriticalCSS(page) {
const withoutQuery = page.split("?")[0];
try {
return (
<style
dangerouslySetInnerHTML={{
__html: fs.readFileSync(
join(process.cwd(), "critters", withoutQuery, "styles.css"),
"utf-8"
),
}}
/>
);
} catch (error) {}
return false;
}
export default function Document(props) {
const criticalCSS = getCriticalCSS(props.dangerousAsPath);
const isCriticalCSSMode = process.env.CRITTERS_RUNTIME && criticalCSS;
return (
<Html lang="en">
<Head>
{isCriticalCSSMode
? criticalCSS
: getCSSPaths(props.dangerousAsPath).map((link) => (
<link key={link} rel="stylesheet" href={link} />
))}
</Head>
<body>
<Main />
<NextScript />
{isCriticalCSSMode &&
getCSSPaths(props.dangerousAsPath).map((link) => (
<link key={link} rel="stylesheet" href={link} />
))}
</body>
</Html>
);
}

To copy styles to the NextJS static folder we need to install copy-webpack-plugin -- yarn add copy-webpack-plugin and add these lines to next.config.js.

const CopyWebpackPlugin = require('copy-webpack-plugin');
const config = {
webpack: (config) => {
config.plugins.push(
new CopyWebpackPlugin({
patterns: [
patterns: [{ from: "styles", to: "static/css" }],
],
}),
);
return config;
},
};
module.exports = config;

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.

CONTACT US TODAY

Don't want to fill out the form? Then contact us by email hi@focusreactive.com