Skip to main content
← Back to blog
Next.jsCSSPerformanceTurbopackSCSS

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.

Published April 8, 202612 min read

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
// 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 ms

Why? 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:

ApproachWhy it doesn't work
Split sectionRegistry into per-section filesCSS still loaded — #61066
experimental.inlineCss: trueInlines CSS on every SSR request — crashed our CMS under load
experimental.optimizeCss (Critters)Incompatible with streaming in App Router
CSS-in-JS rewriteRewriting 90 sections from SCSS to CSS-in-JS is not realistic
media="print" hackDoesn't work with CSS Modules in Turbopack — <link> tags are managed by the framework
Switch back to WebpackHas experiments.css options, but we're committed to Turbopack
Turbopack pluginsDon't exist — no plugin API
Turbopack CSS loadersNot supported — only JS output
SWC plugins for CSSSWC only processes JavaScript
Client Component wrapper for dynamic()Registry is a global constant — bundler sees all dependencies
Next.js middleware HTML rewriteMiddleware can't modify response body
Suspense + streaming with async importReact Float always pulls CSS into initial <head>
Suspense + 3s delayContent streams later, but CSS is in the initial <head> chunk
experimental.cssChunkingAlready default. Merges chunks but doesn't remove irrelevant CSS
Post-build Beasties/CrittersOnly for static export, not with ISR
npm libraries (next-critical, fg-loadcss)Pages Router only or abandoned (6+ years)
inlineCss per-route exclusionNot supported — all-or-nothing global flag
Turbopack as: 'raw' for SCSSReturns undefined instead of text
Turbopack rule for *.inline.module.scssTurbopack intercepts .module.scss before custom rules
Turbopack rule for *.inline.scssTurbopack 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):

@promova/scss-to-inline-loader/index.js
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:

InlineStyle.tsx
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'
HeroSection.tsx
// 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 inlinedALL CSS on the page (94 files)Only CSS for rendered sections (5-8 files)
SSR overheadCSS rebuilt on every requestCSS compiled at build time
Per-route controlNo (all-or-nothing)Yes (per-import)
SSR pages safetyRisky (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:

next.config.ts
// ❌ 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 processable

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

MetricBeforeAfter
Render-blocking CSS files940 (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 featuresFullFull (variables, mixins, nesting, @use)
CSS Modules scopingBuilt-inpostcss-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-onlywith {} attributes aren't supported by Webpack. If you need Webpack fallback, the imports fall back to standard CSS Modules (cssText will be undefined).
  • 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:

  1. You have a registry/barrel pattern — one file imports many components, but only a few render per page
  2. You're on Turbopack — Import Attributes are Turbopack-specific
  3. You want per-component control — not an all-or-nothing flag
  4. Your SCSS is complex — variables, mixins, breakpoints, nesting — all supported
  5. You can't use experimental.inlineCss — because you have SSR pages or want granular control

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

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.