Skip to main content
← Zurück zum Blog
Next.jsCSSPerformanceTurbopackSCSS

Wie ich 94 Render-blockierende CSS-Dateien in Next.js 16 mit einem kaum dokumentierten Turbopack-Feature eliminiert habe

Nach Tagen des Ausprobierens jedes Ansatzes — von experimental.inlineCss bis MutationObserver-Hacks — entdeckte ich Turbopack Import Attributes, die das Render-blockierende CSS-Problem im Next.js App Router lösen.

Veröffentlicht 8. April 202612 Min. Lesezeit

Das Problem

Unsere App (Promova) nutzt Next.js 16 mit einem Landing Builder — einem CMS-gesteuerten System, das Marketingseiten aus ~90 verschiedenen Sektionskomponenten zusammenstellt (Heroes, FAQs, Preise, Bewertungen etc.). Die Architektur nutzt eine sectionRegistry.tsx, die Sektionsnamen auf next/dynamic()-Aufrufe abbildet:

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
}

Eine einzelne Landingpage rendert nur 5-8 Sektionen. Aber Lighthouse zeigte:

Eliminate render-blocking resources
  94 CSS resources (~330 KB)
  Potential savings: 5,440 ms

Warum? Turbopack sieht alle 90 import()-Pfade als erreichbar und generiert <link rel="stylesheet"> für jedes SCSS-Modul. Selbst Sektionen, die nie auf der Seite gerendert werden, bekommen ihr CSS in <head> injiziert. Dies ist ein bestätigtes, erwartetes Verhalten des Next.js App Router. CSS wird für dynamic()-Imports aus Server Components nicht code-gesplittet. Die Maintainer betrachten es als bewussten Kompromiss zur Vermeidung von FOUC. Ein Fix ist nicht geplant.

Alles, was ich versucht habe (und warum es scheiterte)

Ich habe Tage damit verbracht, jeden Ansatz durchzugehen, den ich finden konnte. Hier die vollständige Liste:

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

Die inlineCss-Falle

Next.js hat ein experimental.inlineCss-Flag, das alle <link rel="stylesheet"> durch Inline-<style>-Tags ersetzt. Klingt perfekt, oder?

Das Problem: Es ist alles oder nichts. Man kann es nicht pro Route aktivieren. Bei SSR-(force-dynamic)-Seiten wird bei jedem Request das gesamte CSS inline neu gebaut. Wir haben es versucht — unser headless CMS konnte die Last nicht bewältigen und ging down. Damit es sicher funktioniert, müssen 100% der Seiten force-static oder ISR sein. Mit 20+ SSR-Seiten (Auth, Dashboards, dynamische Seiten) ist das eine massive Migration.

Die Entdeckung: Turbopack Import Attributes

Beim Durchstöbern der Next.js 16.2 Release Notes fand ich ein kaum dokumentiertes Feature: Turbopack Import Attributes. Es erlaubt, die eingebaute Bundler-Pipeline für einen bestimmten Import mit der TC39 with {}-Syntax zu überschreiben:

import { cssText, styles } from './hero.module.scss' with {
  turbopackLoader: '@promova/scss-to-inline-loader',
  turbopackAs: '*.js'
}

Dies sagt Turbopack: "Verarbeite diesen Import nicht als Stylesheet. Führe ihn durch meinen Custom Loader und behandle die Ausgabe als JavaScript."

Das ist die Schlüsselerkenntnis. Anstatt dass Turbopack ein <link rel="stylesheet"> generiert, das das Rendering blockiert, kompiliert unser Loader das SCSS und exportiert es als JS-String. Wir injizieren es dann als Inline-<style>-Tag direkt in die Komponente. Das Ergebnis: Nur das CSS für Sektionen, die tatsächlich rendern, kommt in den HTML-Code der Seite.

Die Lösung

1. Custom Turbopack Loader

Ein ~70-zeiliges Node.js-Script als Yarn-Workspace-Paket (@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')
}

Was es macht: styles — dieselbe Scoped-Klassennamen-Map wie Standard-CSS-Modules. cssText — das kompilierte CSS als String.

2. InlineStyle-Komponente

Nutzt React 19s eingebaute <style href precedence>-API für automatische Deduplizierung:

InlineStyle.tsx
export function InlineStyle({ css, id }: { css: string; id: string }) {
  return (
    <style href={id} precedence="default">
      {css}
    </style>
  )
}

React 19 garantiert: gleicher href → nur ein <style> im DOM.

3. Migration pro Komponente (~6 Zeilen pro Sektion)

// 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>
  )
}

Die .module.scss-Dateien bleiben exakt gleich. Kein CSS-Umschreiben. Stylelint, Prettier, IDE-Support — alles bleibt erhalten.

Warum das besser ist als inlineCss: true

Das ist der entscheidende Unterschied:

<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)

Mit inlineCss: true bekommt eine Seite immer noch ALLE 94 Stylesheets inline. Mit unserem Ansatz kommt nur das CSS, das tatsächlich gerendert wird, ins HTML.

Turbopack-Stolperfalle: Keine globalen Regeln für .module.scss

Eine Falle, in die ich getappt bin: Man könnte denken, dass man eine Turbopack-Regel in next.config.ts hinzufügen kann, um den Loader global anzuwenden:

next.config.ts
// ❌ THIS CAUSES A FATAL PANIC
turbopack: {
  rules: {
    '**/components/**/*.module.scss': {
      loaders: ['@promova/scss-to-inline-loader'],
      as: '*.js'
    }
  }
}

Tun Sie das nicht. Turbopacks eingebaute CSS-Module-Pipeline fängt .module.scss-Dateien ab, bevor Custom-Regeln angewendet werden, was verursacht:

FATAL PANIC: inner asset should be CSS processable

with {}-Attribute funktionieren, weil sie Turbopack an der Import-Stelle anweisen, die CSS-Module-Pipeline vollständig zu umgehen.

Ergebnisse

127 Sektionskomponenten im Landing Builder migriert. Produktions-Build verifiziert.

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)

Einschränkungen

  • with {} pro Import ist ausführlich — jeder Import braucht 3 zusätzliche Zeilen.
  • Nur Turbopackwith {}-Attribute werden von Webpack nicht unterstützt.
  • Klassennamen-Hashing — unser Loader nutzt einen anderen Hashing-Algorithmus als Turbopacks eingebauter.
  • HTML-Größe steigt — CSS ist inline im HTML statt als separate Dateien gecacht.

Wann man das nutzen sollte

Diese Technik ist am effektivsten, wenn:

  1. Sie ein Registry/Barrel-Pattern haben — eine Datei importiert viele Komponenten, aber nur wenige rendern pro Seite
  2. Sie Turbopack nutzen — Import Attributes sind Turbopack-spezifisch
  3. Sie Kontrolle pro Komponente wollen — kein Alles-oder-Nichts-Flag
  4. Ihr SCSS komplex ist — Variablen, Mixins, Breakpoints, Nesting — alles unterstützt
  5. Sie experimental.inlineCss nicht nutzen können — weil Sie SSR-Seiten haben oder granulare Kontrolle wollen

Wenn Sie von Render-blockierendem CSS im Next.js App Router betroffen sind — Sie sind nicht allein:

Das Kernproblem

  • #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 sucht Lösungen

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

Gebaut bei Promova — einer Sprachlernplattform für Millionen von Nutzern.