Skip to main content
Înapoi la blog
Next.jsCSSPerformanceTurbopackSCSS

Cum am eliminat 94 de fișiere CSS care blochează randarea în Next.js 16 cu o funcționalitate Turbopack slab documentată

După zile în care am încercat fiecare abordare — de la experimental.inlineCss până la hack-uri cu MutationObserver — am descoperit Turbopack Import Attributes care rezolvă problema CSS-ului ce blochează randarea în Next.js App Router.

Publicat 8 aprilie 202612 min de citit

Problema

Aplicația noastră (Promova) folosește Next.js 16 cu un Landing Builder — un sistem bazat pe CMS care asamblează pagini de marketing din ~90 de componente de secțiune diferite (heroes, FAQ-uri, prețuri, recenzii etc.). Arhitectura folosește un sectionRegistry.tsx care mapează numele secțiunilor la apeluri next/dynamic():

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
}

O singură pagină de destinație randează doar 5-8 secțiuni. Dar Lighthouse arăta:

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

De ce? Turbopack vede toate cele 90 de căi import() ca fiind accesibile și generează <link rel="stylesheet"> pentru fiecare modul SCSS. Chiar și secțiunile care nu se randează niciodată pe pagină își primesc CSS-ul injectat în <head>. Acesta este un comportament confirmat și așteptat al Next.js App Router. CSS-ul nu este împărțit pe bucăți de cod pentru importurile dynamic() din Server Components. Maintainerii îl consideră un compromis deliberat pentru a preveni FOUC. Nu este planificată o remediere.

Tot ce am încercat (și de ce a eșuat)

Am petrecut zile întregi parcurgând fiecare abordare pe care am putut-o găsi. Iată lista completă:

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

Capcana inlineCss

Next.js are un flag experimental.inlineCss care înlocuiește toate <link rel="stylesheet"> cu etichete <style> inline. Sună perfect, nu?

Problema: este totul sau nimic. Nu îl poți activa per rută. Dacă ai pagini SSR (force-dynamic), fiecare cerere reconstruiește tot CSS-ul inline. Am încercat — CMS-ul nostru headless nu a făcut față încărcării și a picat. Pentru ca acest lucru să funcționeze în siguranță, 100% dintre paginile tale trebuie să fie force-static sau ISR. Cu peste 20 de pagini SSR (autentificare, tablouri de bord, pagini dinamice), aceasta este o migrare masivă.

Descoperirea: Turbopack Import Attributes

Săpând prin notele de lansare Next.js 16.2, am găsit o funcționalitate slab documentată: Turbopack Import Attributes. Îți permite să înlocuiești pipeline-ul de bundling încorporat pentru un import specific folosind sintaxa TC39 with {}:

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

Aceasta îi spune lui Turbopack: "Nu procesa acest import ca pe o foaie de stil. Trece-l prin loader-ul meu personalizat și tratează rezultatul ca JavaScript."

Aceasta este perspectiva-cheie. În loc ca Turbopack să genereze un <link rel="stylesheet"> care blochează randarea, loader-ul nostru compilează SCSS-ul și îl exportă ca un șir JS. Apoi îl injectăm ca o etichetă <style> inline direct în componentă. Rezultatul: doar CSS-ul pentru secțiunile care se randează efectiv ajunge în HTML-ul paginii. O pagină cu 5 secțiuni primește CSS pentru 5 secțiuni — nu pentru 94.

Soluția

1. Loader Turbopack personalizat

Un script Node.js de ~70 de linii ca pachet yarn workspace (@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')
}

Ce face: styles — aceeași hartă de nume de clase cu scop ca la CSS Modules standard. cssText — CSS-ul compilat ca șir de caractere.

2. Componenta InlineStyle

Folosește API-ul încorporat <style href precedence> din React 19 pentru deduplicare automată:

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

React 19 garantează: același href → un singur <style> în DOM. Dacă o secțiune se randează de două ori pe o pagină, CSS-ul este totuși injectat o singură dată.

3. Migrare per componentă (~6 linii per secțiune)

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

Fișierele .module.scss rămân exact la fel. Fără rescrierea CSS-ului. Stylelint, Prettier, suportul IDE — toate păstrate.

De ce este mai bine decât inlineCss: true

Aceasta este diferența critică:

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

Cu inlineCss: true, o pagină tot primește TOATE cele 94 de foi de stil inline — sunt doar etichete <style> în loc de etichete <link>. HTML-ul se umflă. Cu abordarea noastră, doar CSS-ul care se randează efectiv ajunge în HTML. 5 secțiuni randate → 5 etichete <style>. Atât.

Capcana Turbopack: fără reguli globale pentru .module.scss

O capcană în care am căzut: ai putea crede că poți adăuga o regulă Turbopack în next.config.ts pentru a aplica loader-ul global:

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

Nu face asta. Pipeline-ul încorporat de module CSS al Turbopack interceptează fișierele .module.scss înainte ca regulile personalizate să fie aplicate. Creează un asset EcmascriptCssModule care intră în conflict cu ieșirea as: '*.js', cauzând:

FATAL PANIC: inner asset should be CSS processable

Atributele with {} funcționează pentru că îi indică lui Turbopack la locul importului să ocolească complet pipeline-ul de module CSS. Nu există nicio modalitate de a face acest lucru global prin configurare.

Rezultate

Am migrat 127 de componente de secțiune în Landing Builder. Build-ul de producție verificat.

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)

Limitări

  • with {} per import este verbos — fiecare import are nevoie de 3 linii suplimentare. Nicio modalitate de a-l aplica global (vezi capcana de mai sus).
  • Doar Turbopack — atributele with {} nu sunt suportate de Webpack. Dacă ai nevoie de fallback Webpack, importurile revin la CSS Modules standard (cssText va fi undefined).
  • Hashing-ul numelor de clase — loader-ul nostru folosește un algoritm de hashing diferit de cel încorporat în Turbopack. Numele claselor arată diferit în DevTools. Funcțional identice.
  • Dimensiunea HTML crește — CSS-ul este inline în HTML în loc să fie stocat în cache ca fișiere separate. Pentru paginile statice/ISR servite din CDN, aceasta nu este o problemă.

Când să folosești asta

Această tehnică este cea mai eficientă atunci când:

  1. Ai un pattern registry/barrel — un fișier importă multe componente, dar doar câteva se randează per pagină
  2. Ești pe Turbopack — Import Attributes sunt specifice Turbopack
  3. Vrei control per componentă — nu un flag totul-sau-nimic
  4. SCSS-ul tău este complex — variabile, mixin-uri, breakpoint-uri, imbricare — toate suportate
  5. Nu poți folosi experimental.inlineCss — pentru că ai pagini SSR sau vrei control granular

Dacă ești afectat de CSS-ul care blochează randarea în Next.js App Router — nu ești singur:

Problema fundamentală

  • #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

Funcționalitatea inlineCss și problemele

  • 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

Comunitatea caută soluții

Buguri CSS în Turbopack

  • #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

Construit la Promova — o platformă de învățare a limbilor care deservește milioane de utilizatori. Dacă te lupți cu aceeași problemă de CSS, nu ezita să faci referire la acest articol în issue-urile GitHub de mai sus.