Impulseye
FeaturesIntegrationsPricingBlogChangelogIs It Down?
All posts
Engineering

Next.js's official fix for render-blocking CSS does nothing in App Router

PageSpeed flagged 630ms of render-blocking CSS on the homepage. Next.js documents a one-line fix for exactly this. I shipped it, measured zero change, and traced through next/dist to find out why: the fix only exists for a router I'm not using.

August 4, 2026 5 min read

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.

TL;DR

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:

typescript
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:

text
▲ Next.js 16.2.6 (Turbopack)
- Experiments (use with caution):
  · optimizeCss

✓ Compiled successfully

A 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.

html
<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:

text
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.js

Every 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:

text
grep -rl "optimizeCss\|critters" node_modules/next/dist/server/app-render/
→ no matches

Nothing. 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'd tell past me before installing critters

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.
Back to all posts
Share

Monitor your sites with Impulseye

Uptime checks, SSL warnings, and alerts across 9 channels.

Start free

Keep reading

Design 4 min

We rebuilt the public status pages, and found a real bug while doing it

Three reskinned layouts, a live preview while you customize, and a 24-hour timeline chart that was quietly dropping data — twice, until we found the actual cause.

July 31, 2026Read
Engineering 5 min

I built myself a tiny ops dashboard. It found 30 real problems in five minutes.

A personal, owner-only page to stop bouncing between the Hetzner, Supabase, and Resend consoles by hand — and it immediately surfaced a stack of real database performance issues I didn't know I had.

July 19, 2026Read
Infrastructure 7 min

My load test was lying to me, and I almost believed it

Before renting a real server, I built a load-testing rig to find Impulseye's actual ceiling. It reported a ceiling that didn't exist — the test harness itself had two bugs, and both of them looked exactly like a capacity problem.

July 14, 2026Read
Impulseye

Uptime monitoring that is dead simple. Instant alerts via email, Slack, and Discord, set up in under 30 seconds.

support@impulseye.com

Product

  • Features
  • How It Works
  • Integrations
  • Pricing
  • Blog
  • Changelog

Tools

  • Is It Down?
  • Dashboard
  • Status Pages
  • Impulseye Status
  • API Docs
  • FAQ

Legal

  • Terms of Service
  • Privacy Policy
  • Refund Policy

© 2026 Impulseye. All rights reserved.

ContactTermsPrivacy