Skip to main content
← Retour au blog
Next.jsCSSPerformanceTurbopackSCSS

Comment j'ai éliminé 94 fichiers CSS bloquant le rendu dans Next.js 16 avec une fonctionnalité Turbopack peu documentée

Après des jours à essayer chaque approche — de experimental.inlineCss aux hacks MutationObserver — j'ai découvert les Turbopack Import Attributes qui résolvent le problème de CSS bloquant le rendu dans Next.js App Router.

Publié 8 avril 202612 min de lecture

Le problème

Notre application (Promova) utilise Next.js 16 avec un Landing Builder — un système piloté par CMS qui assemble des pages marketing à partir de ~90 composants de section différents (heroes, FAQs, tarifs, avis, etc.). L'architecture utilise un sectionRegistry.tsx qui mappe les noms de section aux appels 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
}

Une seule landing page ne rend que 5-8 sections. Mais Lighthouse affichait :

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

Pourquoi ? Turbopack voit les 90 chemins import() comme atteignables et génère <link rel="stylesheet"> pour chaque module SCSS. Même les sections qui ne sont jamais rendues obtiennent leur CSS injecté dans <head>. C'est un comportement confirmé et attendu du Next.js App Router. Aucun correctif n'est prévu.

Tout ce que j'ai essayé (et pourquoi ça a échoué)

J'ai passé des jours à parcourir chaque approche que j'ai pu trouver. Voici la liste complète :

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

Le piège inlineCss

Next.js a un flag experimental.inlineCss qui remplace tous les <link rel="stylesheet"> par des balises <style> inline. Ça semble parfait, non ?

Le problème : c'est tout ou rien. On ne peut pas l'activer par route. Si vous avez des pages SSR (force-dynamic), chaque requête reconstruit tout le CSS inline. Nous avons essayé — notre CMS headless n'a pas supporté la charge.

La découverte : Turbopack Import Attributes

En fouillant les notes de version Next.js 16.2, j'ai trouvé une fonctionnalité peu documentée : Turbopack Import Attributes. Elle permet de remplacer le pipeline du bundler pour un import spécifique avec la syntaxe TC39 with {} :

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

Cela dit à Turbopack : "Ne traite pas cet import comme une feuille de style. Passe-le par mon loader personnalisé et traite la sortie comme du JavaScript."

C'est l'insight clé. Au lieu que Turbopack génère un <link rel="stylesheet"> bloquant le rendu, notre loader compile le SCSS et l'exporte comme string JS. Résultat : seul le CSS des sections qui sont réellement rendues arrive dans le HTML de la page.

La solution

1. Loader Turbopack personnalisé

Un script Node.js d'environ 70 lignes comme package 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 qu'il fait : styles — la même carte de noms de classes scopés que les CSS Modules standard. cssText — le CSS compilé comme string.

2. Composant InlineStyle

Utilise l'API intégrée de React 19 <style href precedence> pour la déduplication automatique :

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

React 19 garantit : même href → un seul <style> dans le DOM.

3. Migration par composant (~6 lignes par 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>
  )
}

Les fichiers .module.scss restent exactement les mêmes. Aucune réécriture CSS.

Pourquoi c'est mieux que inlineCss: true

Voici la différence critique :

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

Avec inlineCss: true, une page obtient toujours les 94 feuilles de style inline. Avec notre approche, seul le CSS réellement rendu arrive dans le HTML.

Le piège Turbopack : pas de règles globales pour .module.scss

Un piège dans lequel je suis tombé : on pourrait penser qu'on peut ajouter une règle Turbopack dans next.config.ts pour appliquer le loader globalement :

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

Ne faites pas ça. Le pipeline intégré de modules CSS de Turbopack intercepte les fichiers .module.scss avant l'application des règles personnalisées, causant :

FATAL PANIC: inner asset should be CSS processable

Les attributs with {} fonctionnent car ils instruisent Turbopack au site d'import de contourner complètement le pipeline de modules CSS.

Résultats

127 composants de section migrés dans le Landing Builder. Build de production vérifié.

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

  • with {} par import est verbeux — chaque import nécessite 3 lignes supplémentaires.
  • Turbopack uniquement — les attributs with {} ne sont pas supportés par Webpack.
  • Hachage des noms de classe — notre loader utilise un algorithme de hachage différent de celui de Turbopack.
  • La taille du HTML augmente — CSS inline dans le HTML au lieu de fichiers cachés séparés.

Quand l'utiliser

Cette technique est plus efficace quand :

  1. Vous avez un pattern registry/barrel — un fichier importe beaucoup de composants, mais seuls quelques-uns sont rendus par page
  2. Vous êtes sur Turbopack — les Import Attributes sont spécifiques à Turbopack
  3. Vous voulez un contrôle par composant — pas un flag tout ou rien
  4. Votre SCSS est complexe — variables, mixins, breakpoints, imbrication — tout supporté
  5. Vous ne pouvez pas utiliser experimental.inlineCss — parce que vous avez des pages SSR ou voulez un contrôle granulaire

Si vous êtes affecté par le CSS bloquant le rendu dans Next.js App Router — vous n'êtes pas seul :

Le problème central

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

Feature inlineCss et problèmes

  • 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

La communauté cherche des solutions

Bugs CSS de 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 chez Promova — une plateforme d'apprentissage des langues au service de millions d'utilisateurs.