Skip to main content

Critical CSS with NextJS

In this article, we will explore the possible approaches for implementing Critical CSS with different render modes, including, but not limited to, Next JS.

Critical CSS with NextJS

There are different ways and tools to extract Critical CSS, but we will look at the most efficient and scalable tool — Beasties (forked from Critters, as Critters is now deprecated). This tool isn't perfect either, but at least it has the shortest extraction time among Critical CSS tools and is easy to integrate. A possible downside is that there may be scenarios where additional steps might be required before and after optimization. We will explore this in more detail below.

Limitations

While Critical CSS will boost performance (by performance, we mean the first render, First Contentful Paint (FCP), and in some cases, Largest Contentful Paint (LCP)), in almost all cases, we need to highlight that Critical CSS extraction might be tricky when executed during runtime. In a nutshell, to extract Critical CSS, we need the HTML that we plan to send to the user, along with the CSS attached to it. After extraction, we need to modify the original HTML to have the Critical CSS inlined and the original CSS lazy-loaded. While static site generation (SSG) is fairly straightforward and results in static files that can be easily analyzed and modified, server-side rendering (SSR) introduces nuances that can make Critical CSS optimizations less effective. So, we'll apply Critical CSS optimization to static sites only, but we will also explore some possible approaches for dynamic sites.

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 Beasties
  5. Parse HTML after Beasties
  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 Beasties = require("beasties");
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 Beasties({ 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 DOMAfterBeasties = parse(inlined);
const head = DOMAfterBeasties.querySelector("head");

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

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

criticalCSS();

Note: Beasties 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 Beasties 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 Beasties
  9. Parse HTML after Beasties
  10. Add style sheets to <body/> for lazy loading
  11. Save HTML file
const fs = require("fs");
const Beasties = require("beasties");
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 Beasties({ path: currentFolder });
const html = fs.readFileSync(file, "utf-8");
const DOMBeforeBeasties = parse(html);
const uniqueImportantStyles = new Set();

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

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

// if there was inline styles before Beasties
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, DOMAfterBeasties.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 Beasties 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 Beasties
  7. Parse HTML after Beasties
  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 Beasties = require("beasties");
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 beasties = new Beasties({
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 DOMBeforeBeasties = parse(html);
const uniqueImportantStyles = new Set();

// first find all inline styles and add them to Set
for (const style of DOMBeforeBeasties.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 beasties.process(changedToRealPath);
const DOMAfterBeasties = parse(inlined);

// merge all styles form existing <style/> tags into one string
const importantCSS = Array.from(uniqueImportantStyles).join("");
const body = DOMAfterBeasties.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 DOMAfterBeasties.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, DOMAfterBeasties.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 blog boilerplate with critical css (styled-components and regular css)

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.

Looking for NextJS experts?

Learn more about our Next.JS APP router services

Learn more
NextJS App router
NextJS App Router

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', ...]

beasties.js module looks like this.

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

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

async function processHTMLFile(file, htmlString, runtime) {
try {
const beasties = new Beasties();
// 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 beasties.process(changedToRealPath);

const restoredNextJSPath = inlined.replaceAll(
pathPatterns.real,
pathPatterns.original
);

const DOMAfterBeasties = parse(restoredNextJSPath);
const head = DOMAfterBeasties.querySelector("head");

if (head) {
// delete links in the <head/> that left after beasties
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, DOMAfterBeasties.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, DOMAfterBeasties.toString());
}

const inlinedStyles = DOMAfterBeasties.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)
);
}

module.exports = { processHTMLFile };

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

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("./beasties");

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 = "beasties";
let processedRoutes = new Set();
const routes = {};
const cachingTime = 5 * 60 * 1000; // 5 min

try {
console.time("Beasties: runtime prepare");

fs.rmSync(DIR, { recursive: true, force: true });

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

JSON.parse(processedHTMLFiles).forEach((file) => processedRoutes.add(file));

console.timeEnd("Beasties: runtime prepare");
} catch (error) {}

async function saveStylesToFile(html, path) {
const folder = DIR + path;
const styles = await processHTMLFile(path, html, "SSR");

fs.mkdirSync(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("Beasties: runtime");
}

app.prepare().then(() => {
createServer((req, res) => {
const parsedUrl = parse(req.url, true);
const pathname = parsedUrl.pathname;

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")
) {
chunks.push(chunk);
}

originalWrite.apply(res, arguments);
};

res.on("finish", () => {
if (
res.statusCode === 200 &&
res.getHeader("content-type")?.includes("text/html")
) {
processedRoutes.add(pathname);

setTimeout(() => {
console.time("Beasties: runtime");

const html = Buffer.concat(chunks);

zlib.unzip(html, (err, decompressedData) => {
if (err) {
console.error("Error decompressing data:", err);
return;
}

saveStylesToFile(decompressedData.toString(), pathname);

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 && BEASTIES_BUILD=1 node beasties",
"start": "BEASTIES_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(), "beasties", withoutQuery, "styles.css"),
"utf-8"
),
}}
/>
);
} catch (error) {}

return false;
}

export default function Document(props) {
const criticalCSS = getCriticalCSS(props.dangerousAsPath);
const isCriticalCSSMode = process.env.BEASTIES_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;

NextJS runtime critical css optimization example repo

NextJS App Router Critical CSS

App Router now has an experimental inline CSS feature.

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

NextJS App router runtime critical css optimization example repo

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