PageSpeed Insights flagged the homepage for render-blocking CSS — about 630ms of it, spread across a handful of stylesheet files loading before first paint. Next.js has a documented, one-line fix for exactly this: experimental.optimizeCss. I turned it on, built, and measured no change at all. Not “a small improvement” — literally zero. Here’s what was actually going on.
optimizeCssinlines critical CSS via a package called Critters. Critters was built for the Pages Router and needs a fully-rendered HTML document to work with — it can’t process a page as it streams in, which is how the App Router renders everything. Next.js still accepts the flag and prints ✓ optimizeCssin the build output for an App Router project. It just doesn’t do anything. This isn’t a config mistake on my end — it’s a real, still-open gap between the flag and the router most new Next.js apps actually use.
What PageSpeed was actually flagging
The render-blocking chunks were nothing exotic — a few CSS files in _next/static/chunks/, the largest around 24.5 KB taking almost 2 seconds to load on a throttled connection, all loaded as plain <link rel="stylesheet"> tags in the document head before anything could paint. Nothing broken, just the standard cost of shipping a full CSS bundle before the browser can draw a pixel.
The documented fix
Next.js’s own performance docs point at experimental.optimizeCssfor exactly this: it’s supposed to inline the small amount of CSS needed for what’s immediately visible, and load the rest without blocking. One flag in next.config.ts, plus installing the package it depends on:
const nextConfig: NextConfig = {
experimental: {
optimizeCss: true,
},
};optimizeCss is powered by a package called Critters, so that’s a new dependency too. I installed it, added the flag, and ran a production build. Next.js was happy about it:
▲ Next.js 16.2.6 (Turbopack)
- Experiments (use with caution):
· optimizeCss
✓ Compiled successfullyA checkmark, no warnings, clean build. Every sign said it worked.
It didn’t work
I served the production build and pulled the raw HTML for the homepage to see what actually shipped. If critical CSS inlining were working, I’d expect an inline <style> block in the head with the above-the-fold rules, and the full stylesheet deferred so it stops blocking first paint.
<link rel="stylesheet" href="/_next/static/chunks/0-i0d7yqc7p1b.css" data-precedence="next"/>
<link rel="stylesheet" href="/_next/static/chunks/0qsc_~zc.r_l_.css" data-precedence="next"/>
<link rel="stylesheet" href="/_next/static/chunks/13v7p-yx3ekmi.css" data-precedence="next"/>
<link rel="stylesheet" href="/_next/static/chunks/05cq5mu0d9_hk.css" data-precedence="next"/>Same plain, blocking <link> tags as before. No inline <style>block anywhere in the document. The flag had been accepted, the build had succeeded, and the actual output was byte-for-byte the same render-blocking CSS I’d started with.
Tracing it through next/dist
Rather than guess, I grepped Next.js’s own compiled source for every reference to optimizeCss and critters to see what code path actually calls it:
node_modules/next/dist/server/render.js
node_modules/next/dist/pages/_document.js
node_modules/next/dist/export/routes/pages.js
node_modules/next/dist/server/route-modules/pages/pages-handler.js
node_modules/next/dist/server/post-process.jsEvery single match lives under pages/ or a route-modules/pages/path — the Pages Router’s own rendering pipeline. I checked server/app-render/, which is what actually serves an App Router page, specifically for the same terms:
grep -rl "optimizeCss\|critters" node_modules/next/dist/server/app-render/
→ no matchesNothing. The critical-CSS code Next.js ships only ever gets called from the Pages Router’s render path. The flag is still validated and accepted for an App Router project — that’s why the build shows it as active — but the code that would actually do the inlining is never reached, because App Router pages don’t render through that file at all.
Why: streaming
Digging into why turned up the actual architectural reason, and it held up under a wider search, not just my own read of the source. Critters needs a complete, fully-rendered HTML document to analyze — it has to see the whole page to work out which CSS rules are used above the fold. The App Router’s core rendering model is streaming: HTML is sent to the browser in chunks as it’s generated, specifically so the browser can start painting before the whole page exists server-side. Those two things are fundamentally incompatible — there’s no single complete document for Critters to inspect, because by design one never exists at any fixed point in time.
There’s an open Next.js GitHub issue asking for exactly this — critical CSS inlining that actually works under the App Router — showing the same regression with real WebPageTest results: critical CSS that used to inline cleanly under the Pages Router started blocking render the moment the same app moved to app/. It’s closed without a fix landing. This isn’t an edge case I hit by misconfiguring something — it’s a known, unresolved gap between a documented flag and the router most new Next.js projects are built on today.
If a Next.js perf flag’s docs don’t mention App Router by name, check which router its dependency actually needs before you add it — a green build output doesn’t mean the feature ran.
What I actually did
Uninstalled Critters, reverted the config change, and left the render-blocking CSS as a known, accepted cost for now rather than ship a dependency that does nothing. The real fix isn’t a config flag: this project has exactly one CSS import, in the root layout, so every route — including a simple marketing page — ships the entire app’s Tailwind bundle, dashboard styles included. Actually shrinking that means giving route groups their own scoped stylesheets, which is a real restructuring project, not a one-line fix.
- A green build doesn’t mean a feature ran — Next.js validates the flag and reports it as active for any router, even where the underlying implementation is never called.
- Check the rendered output, not the build log — the only way I actually caught this was pulling the real HTML and looking for the inline
<style>block that should have been there. - “Experimental” can mean “Pages Router only” without saying so— worth checking a flag’s actual code path before trusting its docs apply to your router.