How I Eliminated 94 Render-Blocking CSS Files in Next.js 16 with a Poorly Documented Turbopack Feature
After days of trying every approach — from experimental.inlineCss to MutationObserver hacks — I discovered Turbopack Import Attributes that solve the render-blocking CSS problem in Next.js App Router.
The Problem
Our app (Promova) uses Next.js 16 with a Landing Builder — a CMS-driven system that assembles marketing pages from ~90 different section components (heroes, FAQs, pricing, reviews, etc.). The architecture uses a sectionRegistry.tsx that maps section names to next/dynamic() calls:
// sectionRegistry.tsx — imports all 90 sections
export const sectionRegistry = {
HeroSection: dynamic(() => import('./HeroSection/HeroSection')),
FAQSection: dynamic(() => import('./FAQSection/FAQSection')),
ReviewsSection: dynamic(() => import('./ReviewsSection/ReviewsSection')),
// ... 87 more
}A single landing page only renders 5-8 sections. But Lighthouse was showing:
Eliminate render-blocking resources
94 CSS resources (~330 KB)
Potential savings: 5,440 msWhy? Turbopack sees all 90 import() paths as reachable and generates <link rel="stylesheet"> for every SCSS module. Even sections that never render on the page get their CSS injected into <head>. This is a confirmed, expected behavior of Next.js App Router. CSS is not code-split for dynamic() imports from Server Components. The maintainers consider it a deliberate trade-off to prevent FOUC. A fix is not planned.
Everything I Tried (and Why It Failed)
I spent days going through every approach I could find. Here's the full list:
| Approach | Why it doesn't work |
|---|---|
Split sectionRegistry into per-section files | CSS still loaded — #61066 |
experimental.inlineCss: true | Inlines CSS on every SSR request — crashed our CMS under load |
experimental.optimizeCss (Critters) | Incompatible with streaming in App Router |
| CSS-in-JS rewrite | Rewriting 90 sections from SCSS to CSS-in-JS is not realistic |
media="print" hack | Doesn't work with CSS Modules in Turbopack — <link> tags are managed by the framework |
| Switch back to Webpack | Has experiments.css options, but we're committed to Turbopack |
| Turbopack plugins | Don't exist — no plugin API |
| Turbopack CSS loaders | Not supported — only JS output |
| SWC plugins for CSS | SWC only processes JavaScript |
Client Component wrapper for dynamic() | Registry is a global constant — bundler sees all dependencies |
| Next.js middleware HTML rewrite | Middleware can't modify response body |
| Suspense + streaming with async import | React Float always pulls CSS into initial <head> |
| Suspense + 3s delay | Content streams later, but CSS is in the initial <head> chunk |
experimental.cssChunking | Already default. Merges chunks but doesn't remove irrelevant CSS |
| Post-build Beasties/Critters | Only for static export, not with ISR |
| npm libraries (next-critical, fg-loadcss) | Pages Router only or abandoned (6+ years) |
inlineCss per-route exclusion | Not supported — all-or-nothing global flag |
Turbopack as: 'raw' for SCSS | Returns undefined instead of text |
Turbopack rule for *.inline.module.scss | Turbopack intercepts .module.scss before custom rules |
Turbopack rule for *.inline.scss | Turbopack intercepts .scss before custom rules |
MutationObserver <script> | Tested in dev, risky — CSS still loads as separate files, possible FOUC |
The inlineCss Trap
Next.js has an experimental.inlineCss flag that replaces all <link rel="stylesheet"> with inline <style> tags. Sounds perfect, right?
The problem: it's all-or-nothing. You can't enable it per-route. If you have SSR (force-dynamic) pages, every request rebuilds all CSS inline. We tried it — our headless CMS couldn't handle the load and went down. For it to work safely, 100% of your pages need to be force-static or ISR. With 20+ SSR pages (auth, dashboards, dynamic pages), that's a massive migration.
The Discovery: Turbopack Import Attributes
While digging through Next.js 16.2 release notes, I found a poorly documented feature: Turbopack Import Attributes. It lets you override the built-in bundler pipeline for a specific import using the TC39 with {} syntax:
import { cssText, styles } from './hero.module.scss' with {
turbopackLoader: '@promova/scss-to-inline-loader',
turbopackAs: '*.js'
}This tells Turbopack: "Don't process this import as a stylesheet. Run it through my custom loader and treat the output as JavaScript."
This is the key insight. Instead of Turbopack generating a <link rel="stylesheet"> that blocks rendering, our loader compiles the SCSS and exports it as a JS string. We then inject it as an inline <style> tag directly in the component. The result: only the CSS for sections that actually render makes it into the page HTML. A page with 5 sections gets CSS for 5 sections — not 94.
The Solution
1. Custom Turbopack Loader
A ~70-line Node.js script as a yarn workspace package (@promova/scss-to-inline-loader):
const { createHash } = require('node:crypto')
const sass = require('sass')
const postcss = require('postcss')
const postcssModules = require('postcss-modules')
const path = require('path')
const SHARED_STYLES = path.resolve(__dirname, '../shared/styles')
// Same prependData as sassOptions.prependData in next.config.ts
const PREPEND_DATA = `
@use "sass:math" as math;
@use "sass:list" as list;
@use "${SHARED_STYLES}/_variables.scss" as *;
@use "${SHARED_STYLES}/_breakpoints.scss" as *;
@use "${SHARED_STYLES}/_mixins.scss" as *;
`
module.exports = function scssToInlineLoader(source) {
const callback = this.async()
processScss(source, this.resourcePath)
.then((js) => callback(null, js))
.catch((err) => callback(err))
}
async function processScss(source, resourcePath) {
// 1. Compile SCSS → CSS (with global variables, mixins, breakpoints)
const sassResult = sass.compileString(PREPEND_DATA + '\n' + source, {
loadPaths: [path.dirname(resourcePath), SHARED_STYLES],
style: 'compressed',
sourceMap: false,
url: new URL('file://' + resourcePath),
})
// 2. Scope class names with postcss-modules (CSS Modules compatible)
let classNames = {}
const result = await postcss([
postcssModules({
generateScopedName(name, filename) {
const file = path.basename(filename, path.extname(filename))
.replace('.module', '')
const hash = createHash('md5')
.update(filename + name)
.digest('base64url')
.slice(0, 6)
return `${file}__${name}__${hash}`
},
getJSON(_, json) { classNames = json },
}),
]).process(sassResult.css, { from: resourcePath })
// 3. Export as JS module — same interface as CSS Modules
return [
`export const cssText = ${JSON.stringify(result.css)};`,
`export const styles = ${JSON.stringify(classNames)};`,
`export default styles;`,
].join('\n')
}What it does: styles — the same scoped class name map as standard CSS Modules. cssText — the compiled CSS as a string.
2. InlineStyle Component
Uses React 19's built-in <style href precedence> API for automatic deduplication:
export function InlineStyle({ css, id }: { css: string; id: string }) {
return (
<style href={id} precedence="default">
{css}
</style>
)
}React 19 guarantees: same href → only one <style> in DOM. If a section renders twice on a page, CSS is still injected once.
3. Per-Component Migration (~6 lines per section)
// BEFORE: Turbopack → <link rel="stylesheet"> (render-blocking)
import styles from './hero_section.module.scss'// AFTER: Custom loader → JS module → inline <style> (non-blocking)
import { cssText, styles } from './hero_section.module.scss' with {
turbopackLoader: '@promova/scss-to-inline-loader',
turbopackAs: '*.js'
}
import { InlineStyle } from '@promova/scss-to-inline-loader/InlineStyle'
export function HeroSection() {
return (
<section className={styles.hero}>
<InlineStyle css={cssText} id="hero-section" />
{/* ... component JSX, className usage is identical */}
</section>
)
}The .module.scss files stay exactly the same. No CSS rewriting. Stylelint, Prettier, IDE support — all preserved.
Why This Is Better Than inlineCss: true
This is the critical difference:
| <code>inlineCss: true</code> | Import Attributes + Loader | |
|---|---|---|
| What gets inlined | ALL CSS on the page (94 files) | Only CSS for rendered sections (5-8 files) |
| SSR overhead | CSS rebuilt on every request | CSS compiled at build time |
| Per-route control | No (all-or-nothing) | Yes (per-import) |
| SSR pages safety | Risky (overloads server) | Safe (component-level) |
With inlineCss: true, a page still gets ALL 94 stylesheets inlined — they're just <style> tags instead of <link> tags. The HTML gets bloated. With our approach, only the CSS that actually renders makes it into the HTML. 5 sections rendered → 5 <style> tags. That's it.
The Turbopack Gotcha: No Global Rules for .module.scss
One trap I fell into: you might think you can add a Turbopack rule in next.config.ts to apply the loader globally:
// ❌ THIS CAUSES A FATAL PANIC
turbopack: {
rules: {
'**/components/**/*.module.scss': {
loaders: ['@promova/scss-to-inline-loader'],
as: '*.js'
}
}
}Don't do this. Turbopack's built-in CSS module pipeline intercepts .module.scss files before custom rules are applied. It creates an EcmascriptCssModule asset that conflicts with the as: '*.js' output, causing:
FATAL PANIC: inner asset should be CSS processableThe with {} attributes work because they instruct Turbopack at the import site to bypass the CSS module pipeline entirely. There is no way to do this globally via config.
Results
Migrated 127 section components across the Landing Builder. Production build verified.
| Metric | Before | After |
|---|---|---|
| Render-blocking CSS files | 94 | 0 (for landing sections) |
| CSS in HTML per page | ~330 KB (all sections) | ~20-40 KB (rendered sections only) |
| CSS delivery | <link> (network request, blocking) | <style> (inline, non-blocking) |
| Sass features | Full | Full (variables, mixins, nesting, @use) |
| CSS Modules scoping | Built-in | postcss-modules (compatible) |
| Code change per section | — | ~6 lines (import + InlineStyle) |
Limitations
- Per-import
with {}is verbose — each import needs 3 extra lines. No way to apply globally (see gotcha above). - Turbopack-only —
with {}attributes aren't supported by Webpack. If you need Webpack fallback, the imports fall back to standard CSS Modules (cssTextwill beundefined). - Class name hashing — our loader uses a different hashing algorithm than Turbopack's built-in one. Class names look different in DevTools. Functionally identical.
- HTML size increases — CSS is inline in HTML instead of cached as separate files. For static/ISR pages served from CDN, this is a non-issue.
When to Use This
This technique is most effective when:
- You have a registry/barrel pattern — one file imports many components, but only a few render per page
- You're on Turbopack — Import Attributes are Turbopack-specific
- You want per-component control — not an all-or-nothing flag
- Your SCSS is complex — variables, mixins, breakpoints, nesting — all supported
- You can't use
experimental.inlineCss— because you have SSR pages or want granular control
Related GitHub Issues
If you're affected by render-blocking CSS in Next.js App Router, you're not alone:
The Core Problem
- #62485 — Render blocking CSS (maintainers: "expected behavior")
- #61066 — Dynamic imports from Server Components are not code-split
- #54935 — Server-side dynamic imports don't split client modules
- #61574 — JS/CSS code splitting doesn't work as documented
- #57634 — Add support for critical CSS inlining with App Router
- #50300 — next/dynamic on server component does not build CSS modules
inlineCss Feature & Issues
- PR #72195 — experimental: css inlining (the implementation)
- PR #73182 — Don't inline CSS in RSC payload for client navigation
- #75648 — WhatsApp preview broken with inlineCss + Tailwind
- #83612 — Turbopack wrong font URL with inline CSS
Community Asking for Solutions
- Discussion #82894 — How to prevent render-blocking with CSS Modules? (2025)
- Discussion #70526 — Ideas for reducing render-blocking CSS
- Discussion #59814 — Render blocking styles with Tailwind
- Discussion #49691 — How to deal with render-blocking modular CSS
- Discussion #59989 — Critical CSS inlining with App Router
- Discussion #80486 — Is optimizeCss still in use?
- Discussion #85465 — Turbopack plugin API (doesn't exist)
Turbopack CSS Bugs
- #68412 — Incorrect CSS modules load order with external components
- #76464 — Turbopack sometimes strips SCSS imports
- #82497 — SCSS module not loading when not-found.tsx is present
- #88544 — Exporting Sass variables from CSS modules doesn't work with Turbopack
Built at Promova — a language learning platform serving millions of users. If you're fighting the same CSS problem, feel free to reference this article in the GitHub issues above.