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.
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 — 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 msPourquoi ? 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 :
| 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 |
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) :
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 :
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'// 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 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) |
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 :
// ❌ 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 processableLes 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é.
| 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
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 :
- Vous avez un pattern registry/barrel — un fichier importe beaucoup de composants, mais seuls quelques-uns sont rendus par page
- Vous êtes sur Turbopack — les Import Attributes sont spécifiques à Turbopack
- Vous voulez un contrôle par composant — pas un flag tout ou rien
- Votre SCSS est complexe — variables, mixins, breakpoints, imbrication — tout supporté
- Vous ne pouvez pas utiliser
experimental.inlineCss— parce que vous avez des pages SSR ou voulez un contrôle granulaire
Issues GitHub liées
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
- 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)
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.