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.

Rosen 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

Engineering 6 min

Retiring a monitor nobody's watching, without deleting one somebody cares about

Paused monitors still cost something to keep around. Removing them automatically is easy; removing them without ever surprising someone is the actual design problem. Here's the 25 → 85 → 90 day path we landed on.

September 10, 2026Read
Guide 6 min

Why every website needs uptime monitoring

Your users don't file a ticket when your site goes down — they just leave, and you find out from a support email or a revenue graph, hours after the fact. Here's what actually catching it early looks like.

August 18, 2026Read
Guide 6 min

Why your uptime monitor keeps sending false alerts

A monitor that pages you for a blip that fixed itself in ten seconds isn't protecting you — it's training you to ignore it. Here's where false alerts actually come from, and how to make the real ones stand out.

August 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