Add support for critical CSS inlining with App Router #59989
Replies: 24 comments 93 replies
-
There is no way to inline critical CSS on App router. I can't find any solution to do it now. I don't know why Nextjs not support for App router @mlstubblefield do you have any solution to solve it now? |
Beta Was this translation helpful? Give feedback.
-
Yea, it's a bummer- we saw huuuge improvements in our FCP when we first added critters, so I'm sad to see it no longer function. |
Beta Was this translation helpful? Give feedback.
This comment was marked as off-topic.
This comment was marked as off-topic.
-
Critters does not support streaming. This experimental flag works in the Pages Router (no streaming) but does not work with the App Router (has streaming). I've updated this issue to be a feature request and converted it to a discussion. |
Beta Was this translation helpful? Give feedback.
-
Our results in the field were quite significant. Here are our field results using optimizeCss. |
Beta Was this translation helpful? Give feedback.
-
@leerob any update on this request? |
Beta Was this translation helpful? Give feedback.
-
I'm testing a postbuild script to get inlined critical css with critters on html files for SSG pages, for the moment just in preview branches and on the homepage only. import Critters from "critters";
import fs from "fs";
const critters = new Critters({
path: ".next/static/css",
publicPath: "/_next/static/css/",
inlineFonts: true,
preloadFonts: false, // next is already preloading them
});
async function editHome() {
const inlined = await critters.process(
fs.readFileSync(".next/server/app/index.html", "utf8")
);
fs.writeFileSync(".next/server/app/index.html", inlined);
}
new Promise(() => editHome()); |
Beta Was this translation helpful? Give feedback.
-
tldr: Under Chrome "Slow 3G" conditions, it appears that inlining critical css makes the difference between good and mediocre/poor Core Web Vitals outcomes using the app router. I have performed some lab benchmarks with a small GKE-deployed NextJS 14.1.0 ssr application with some suspended async operations (waterfall screenshot below). The waterfall suggests that inlining critical CSS would have a substantial positive performance impact on FCP in high-latency and/or low-bandwidth situations. Below is a screenshot of the waterfall for the site, with disabled cache and Chrome "Slow 3G" (2 seconds request latency, 400 kbps down). Because Chrome adds two seconds of latency to each request, the fully-loaded HTML document is available two seconds before the critical (external) CSS finishes loading. (In low-bandwith conditions, even with fetch priority set properly, the external CSS is competing with the loading Javascript, making it load even more slowly. If the CSS were inlined, the high latency and low bandwidth means the inline CSS/html has several seconds at 400 kbps down where it is the only resource being fetched, so high latency might actually help FCP in low-bandwidth situations!) From a "qualitative" perspective, the slow FCP in this situation makes a lot of the (really cool) NextJS preloading UI optimizations less impressive, because the suspended SSR (especially with non-dynamic routes) finishes long before critical CSS is even ready! Without inlining critical CSS under these network conditions, preloading only looks good if the browser has a warm cache. With my use case, I expect greater-than-normal visits under bad network conditions and no browser cache. The most important site content is text, and I want to display that immediately. (NextJS caching is a fantastic tool for this!) Aside from the inline CSS issue, the NextJS app router is performing beautifully. (In fact, CWV for mobile/desktop are generally well above 90%.) However, I feel bad that in spite of the technical backflips I'm doing, anyone inlining their critical CSS is going to massively outperform my app on FCP in bad network conditions. I am also particularly sensitive to performance under bad conditions because I am hoping to use benchmarks as marketing materials (both personally as a developer and for the product), and it's tough to give a talk on web performance when your critical CSS is not inline. In some ways, I feel like the sword character from Indiana Jones, doing weeks of next-gen optimizations and being taken out by style={{backgroundColor: 'black'}}. If I am misreading or misinterpreting that waterfall screenshot, please let me know. As always, thank you maintainers! |
Beta Was this translation helpful? Give feedback.
-
This is very important to get it working with the App Router! |
Beta Was this translation helpful? Give feedback.
-
It's so frustrating, guys. At this point im pretty much regret all this time spent. could live with "not ideal" ssr stuff in page router Sorry for the rant, but it was really painful and hard to pull off and now i guess i will have to revert refactoring because of this css stuff |
Beta Was this translation helpful? Give feedback.
-
Hi. Is there an update about adding support for Critical CSS? I Spent more than a week migrating my app to app-roter only to realize this feature was missing. It was a lapse on my part not to notice this before migration 😞 Considering how significant this can be for the page-load experience boost, I can't help but wait to have the support added to take my effort and app to production If anyone is already working on this please do mention it here... I'd be happy to help in any way possible. |
Beta Was this translation helpful? Give feedback.
-
Similar result from https://danielnagy.me/posts/Post_tsr8q6sx37pl. Inlining critical CSS matters on mobile according to this benchmark: https://youtu.be/1gZmkpsVGkk?si=q8pNnWqkJLYzqjlt Sebastian made a similar point: |
Beta Was this translation helpful? Give feedback.
-
HI. Since there are problems dealing with critical css with dynamic routes, why aren't you just providing a possibility to use a global css embedded in the head? Many projects only use a global css with tailwind and this solution already solves many performance problems and user experience. This is frustrating, an entire application being limited to already known performance problems. |
Beta Was this translation helpful? Give feedback.
-
Hi everyone, Just want to give an update that our team has recently discussed critters/this discussion further! As mentioned above, this is unfortunately currently not compatible with App Router because of streaming. We're currently investigating how this would compare with Partial Prerendering (PPR) via a method like link-header prefetching, which could be an alternative way of achieving the same performance benefits as critters. We will report back on this thread with new updates! |
Beta Was this translation helpful? Give feedback.
-
Method for SSG static pages onlyI've worked a few hours on my previous script and I got it working. No css is loaded on first-page landing (when the html file is effectively used), so no blocking request (actually no css request at all) nor additional css kbs. In my case I had no significant improvement on performance tests, so I haven't fully tested it, as I'm probably not gonna use it in production. Anyway I got a perfect layout on all the pages I tested, within a quite complex tailwind based app. Hope it might be helpful for someone! Implementation
"postbuild": "tsx src/postbuild.ts",
/* eslint-disable no-useless-escape */
import { load } from 'cheerio';
import Critters from 'critters';
import fs from 'fs';
import path from 'path';
const critters = new Critters({
path: '.next/static/css',
publicPath: '/_next/static/css/',
inlineFonts: true,
preloadFonts: false,
preload: 'body',
logLevel: 'error',
});
function fromDir(startPath: string, filter: string) {
if (!fs.existsSync(startPath)) {
console.log('No .next folder found. Aborting.');
} else {
const files = fs.readdirSync(startPath);
// iterates over files and folders
Promise.all(
files.map(async file => {
const filename = path.join(startPath, file);
const stat = fs.lstatSync(filename);
// recursion in case stat is directory
if (stat?.isDirectory()) {
fromDir(filename, filter);
// checks if the file is an html
} else if (filename.endsWith(filter)) {
// inlines css and moves <link rel=stylesheet>s to body
const inlined = await critters.process(
fs.readFileSync(filename, 'utf8')
);
//writes critters' changes
fs.writeFileSync(filename, inlined);
//loads page on cheerio (html editor)
const page = load(fs.readFileSync(filename, 'utf8'));
page('script')?.each((i, elem) => {
const text = page(elem).text();
// If a <script> tag (RSC payload) contains a css import, removes it
if (text?.match(/\[[^\]^\[]+_next\/static\/css\/.*?\.css.*?\]/)) {
const newText = text.replaceAll(
/\[[^\]^\[]+_next\/static\/css\/.*?\.css.*?\]/g,
'[]'
);
page(elem).text(newText);
}
});
//removes <link rel=stylesheet>s to avoid css loading.
// Remove this part in case you might need non-blocking css load
page('[rel="stylesheet"]')?.each((i, elem) => {
page(elem).remove();
});
// writes cheerio's changes
fs.writeFileSync(filename, page.html());
}
})
);
}
}
const promise = new Promise(() => fromDir('.next/server/app', '.html'));
promise.then(() => console.log('Postbuild done'));
|
Beta Was this translation helpful? Give feedback.
-
@samcx Do we have any ETA on this? This issue is having a lot of impact on our LCP and impacting our SEO performance. |
Beta Was this translation helpful? Give feedback.
-
Terribly disappointed. Not a fan of React, even less of a fan of React-based server frameworks, only confirms negative perceptions about this part of frontend-land. It's an important feature for a server framework - please add support! |
Beta Was this translation helpful? Give feedback.
-
@gujral1997 v15 has necessarily landed yet—it's still a release candidate. It's going to land with React v19 when that's released on latest.
There is work being done for this (although not directly mentioned in the PR) → #67715. We will continue to share any important updates as they come! |
Beta Was this translation helpful? Give feedback.
-
Was this fixed in NextJs version 15? |
Beta Was this translation helpful? Give feedback.
-
Why does Lighthouse report that I only have this problem in the mobile version? |
Beta Was this translation helpful? Give feedback.
-
Post-build script I use whenever I need to inline CSS: import * as fs from 'fs'
import * as path from 'path'
import * as cheerio from 'cheerio'
const html_dir = path.join(process.cwd(), 'out')
// Function to read and process each HTML file
const process_files = (dir: string): void => {
// Function to process a single HTML file
const process_file = (file_path: string): void => {
const html_content = fs.readFileSync(file_path, 'utf8')
const $ = cheerio.load(html_content)
$('link[rel="stylesheet"]').each((_, element) => {
const href = $(element).attr('href')
if (href) {
const css_file_path = path.join(html_dir, href)
const css_content = fs.readFileSync(css_file_path, 'utf8')
$(element).replaceWith(`<style>${css_content}</style>`)
}
})
fs.writeFileSync(file_path, $.html(), 'utf8')
}
fs.readdirSync(dir).forEach((file) => {
const file_path = path.join(dir, file)
const stat = fs.statSync(file_path)
if (stat.isDirectory()) {
process_files(file_path) // Recurse into subdirectories
} else if (path.extname(file) == '.html') {
process_file(file_path) // Process HTML file
}
})
}
process_files(html_dir) |
Beta Was this translation helpful? Give feedback.
-
Wild. I was wondering why our app core web vitals got so much worse after app router. Seems like hacky solution mentioned before is the answer for now. |
Beta Was this translation helpful? Give feedback.
-
Thank for all for your patience here. We're working on an experimental flag to enable CSS inlining, for both webpack/turbopack. You can follow along on the PR: #72195 |
Beta Was this translation helpful? Give feedback.
-
We have a multi tenant product and none of the solutions worked. In addition to that the preload link header still isn't good enough. I tried to solve critical CSS inlining for Next.js app router and got it working (sort of) with a post-build script for non-static pages. Here's how:
const CriticalCss = () => {
const isServerInserted = useRef(false); // prevent double insertion
useServerInsertedHTML(() => {
if (!isServerInserted.current) {
isServerInserted.current = true;
return (
<style
dangerouslySetInnerHTML={{ __html: '{CRITICAL_CSS}' }}
/>
);
}
return null;
});
return null;
};
{process.env.NODE_ENV === 'production' && <CriticalCss />}
import fs from 'node:fs/promises';
import path from 'node:path';
const APP_PLATFORM = process.env.APP_PLATFORM;
const distFolder = `dist/${APP_PLATFORM}`;
const manifestPath = path.join(process.cwd(), `${distFolder}/app-build-manifest.json`);
async function replacePlaceholderInFile(
filePath: string,
criticalCss: string
): Promise<boolean> {
try {
const content = await fs.readFile(filePath, 'utf-8');
if (content.includes('{CRITICAL_CSS}')) {
console.log(`Found placeholder in: ${filePath}`);
const newContent = content.replace('{CRITICAL_CSS}', criticalCss);
await fs.writeFile(filePath, newContent);
return true;
}
return false;
} catch (error) {
console.error(`Error processing file ${filePath}:`, error);
return false;
}
}
async function removeCssLinkFromFile(
filePath: string,
paths: string[]
): Promise<boolean> {
try {
const content = await fs.readFile(filePath, 'utf-8');
let newContent = content;
// Simply remove the exact string from all file types
for (const cssPath of paths) {
newContent = newContent.replaceAll(cssPath, '');
}
const modified = newContent !== content;
if (modified) {
await fs.writeFile(filePath, newContent);
console.log(`Modified: ${filePath}`);
}
return modified;
} catch (error) {
console.error(`Error processing file ${filePath}:`, error);
return false;
}
}
async function traverseDirectory(
dir: string,
callback: (filePath: string) => Promise<boolean>
): Promise<number> {
const entries = await fs.readdir(dir, { withFileTypes: true });
let replacements = 0;
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
replacements += await traverseDirectory(fullPath, callback);
continue;
}
if (entry.isFile() && !entry.name.endsWith('.map')) {
// Skip source map files and check others
const replaced = await callback(fullPath);
if (replaced) replacements++;
}
}
return replacements;
}
async function main() {
/**
* Reads the app build manifest to find the css files
*/
const manifestFile = await fs.readFile(manifestPath, 'utf-8');
const manifest = JSON.parse(manifestFile) as {
pages: Record<string, string[]>;
};
/**
* Gets all css files that are set on the /layout
*/
const layout = manifest.pages['/layout'];
const criticalFiles = layout.filter((file) => file.endsWith('.css'));
const criticalCssContents = await Promise.all(
criticalFiles.map((filePath) => {
return fs.readFile(`${distFolder}/${filePath}`, 'utf-8');
})
);
/**
* Join the contents to replace the component content
*/
const criticalCss = criticalCssContents
.join('')
.replace(/`/g, '\\`')
.replace(/\$/g, '\\$')
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r');
/**
* Replace the placeholder on all files
*/
const placeholderReplacements = await traverseDirectory(
distFolder,
async (filePath) => {
return await replacePlaceholderInFile(filePath, criticalCss);
}
);
console.log(`✅ Replaced {CRITICAL_CSS} in ${placeholderReplacements} files`);
/**
* Prevent including the css as link
*/
const removedLinksFrom = await traverseDirectory(distFolder, async (filePath) => {
return await removeCssLinkFromFile(filePath, criticalFiles);
});
console.log(`✅ Removed ${removedLinksFrom} links from ${criticalFiles}`);
}
main().catch(console.error);
Important: it's under development and I haven't tested everything to see if it blows up. This script inlines all .css files found on @leerob is there any way to tell next to put this higher in the head? precedence or data-precedence had no effect. |
Beta Was this translation helpful? Give feedback.
-
To Reproduce
const nextConfig = { experimental:{ optimizeCss: true } }
Critical CSS is blocking :( and not optimized!
Current vs. Expected behavior
Before the appDir, critical css was inlined and FCP would be really really fast.
Now, you can see that FCP is behind the blocking CSS!
Just look at this waterfall https://www.webpagetest.org/result/231027_BiDcZF_FDJ/1/details/#waterfall_view_step1
Beta Was this translation helpful? Give feedback.
All reactions