Cómo eliminé 94 archivos CSS que bloquean el renderizado en Next.js 16 con una característica poco documentada de Turbopack
Después de días probando cada enfoque — desde experimental.inlineCss hasta hacks con MutationObserver — descubrí Turbopack Import Attributes que resuelven el problema de CSS que bloquea el renderizado en Next.js App Router.
El problema
Nuestra aplicación (Promova) usa Next.js 16 con un Landing Builder — un sistema impulsado por CMS que ensambla páginas de marketing a partir de ~90 componentes de sección diferentes (heroes, FAQs, precios, reseñas, etc.). La arquitectura usa un sectionRegistry.tsx que mapea nombres de sección a llamadas 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
}Una sola landing page solo renderiza 5-8 secciones. Pero Lighthouse mostraba:
Eliminate render-blocking resources
94 CSS resources (~330 KB)
Potential savings: 5,440 ms¿Por qué? Turbopack ve las 90 rutas import() como alcanzables y genera <link rel="stylesheet"> para cada módulo SCSS. Incluso secciones que nunca se renderizan obtienen su CSS inyectado en <head>. Este es un comportamiento confirmado y esperado del Next.js App Router. No se planea una corrección.
Todo lo que intenté (y por qué falló)
Pasé días repasando cada enfoque que pude encontrar. Aquí está la lista completa:
| 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 |
La trampa de inlineCss
Next.js tiene un flag experimental.inlineCss que reemplaza todos los <link rel="stylesheet"> con etiquetas <style> inline. ¿Suena perfecto, verdad?
El problema: es todo o nada. No se puede activar por ruta. Si tienes páginas SSR (force-dynamic), cada solicitud reconstruye todo el CSS inline. Lo probamos — nuestro headless CMS no soportó la carga y se cayó.
El descubrimiento: Turbopack Import Attributes
Investigando las notas de la versión Next.js 16.2, encontré una característica poco documentada: Turbopack Import Attributes. Permite anular el pipeline del bundler para un import específico usando la sintaxis TC39 with {}:
import { cssText, styles } from './hero.module.scss' with {
turbopackLoader: '@promova/scss-to-inline-loader',
turbopackAs: '*.js'
}Esto le dice a Turbopack: "No proceses este import como hoja de estilos. Pásalo por mi loader personalizado y trata la salida como JavaScript."
Este es el insight clave. En lugar de que Turbopack genere un <link rel="stylesheet"> que bloquea el renderizado, nuestro loader compila el SCSS y lo exporta como string JS. Resultado: solo el CSS de las secciones que realmente se renderizan llega al HTML de la página.
La solución
1. Loader personalizado de Turbopack
Un script Node.js de ~70 líneas como paquete 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')
}Qué hace: styles — el mismo mapa de nombres de clase con scope que CSS Modules estándar. cssText — el CSS compilado como string.
2. Componente InlineStyle
Usa la API integrada de React 19 <style href precedence> para deduplicación automática:
export function InlineStyle({ css, id }: { css: string; id: string }) {
return (
<style href={id} precedence="default">
{css}
</style>
)
}React 19 garantiza: mismo href → solo un <style> en el DOM.
3. Migración por componente (~6 líneas por sección)
// 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>
)
}Los archivos .module.scss permanecen exactamente iguales. Sin reescritura de CSS.
Por qué esto es mejor que inlineCss: true
Esta es la diferencia crítica:
| <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) |
Con inlineCss: true, una página sigue obteniendo TODOS los 94 estilos inline. Con nuestro enfoque, solo el CSS que realmente se renderiza llega al HTML.
La trampa de Turbopack: sin reglas globales para .module.scss
Una trampa en la que caí: podrías pensar que puedes agregar una regla de Turbopack en next.config.ts para aplicar el loader globalmente:
// ❌ THIS CAUSES A FATAL PANIC
turbopack: {
rules: {
'**/components/**/*.module.scss': {
loaders: ['@promova/scss-to-inline-loader'],
as: '*.js'
}
}
}No lo hagas. El pipeline integrado de módulos CSS de Turbopack intercepta archivos .module.scss antes de que se apliquen reglas personalizadas, causando:
FATAL PANIC: inner asset should be CSS processableLos atributos with {} funcionan porque instruyen a Turbopack en el sitio de importación a omitir completamente el pipeline de módulos CSS.
Resultados
Migrados 127 componentes de sección en el Landing Builder. Build de producción verificado.
| 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) |
Limitaciones
with {}por import es verboso — cada import necesita 3 líneas extra.- Solo Turbopack — los atributos
with {}no son soportados por Webpack. - Hashing de nombres de clase — nuestro loader usa un algoritmo de hashing diferente al de Turbopack.
- El tamaño del HTML aumenta — CSS inline en HTML en lugar de archivos cacheados separados.
Cuándo usar esto
Esta técnica es más efectiva cuando:
- Tienes un patrón registry/barrel — un archivo importa muchos componentes, pero solo unos pocos se renderizan por página
- Estás en Turbopack — Import Attributes son específicos de Turbopack
- Quieres control por componente — no un flag de todo o nada
- Tu SCSS es complejo — variables, mixins, breakpoints, anidación — todo soportado
- No puedes usar
experimental.inlineCss— porque tienes páginas SSR o quieres control granular
Issues de GitHub relacionados
Si te afecta el CSS que bloquea el renderizado en Next.js App Router — no estás solo:
El problema 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 y problemas
- 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 comunidad busca soluciones
- 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 de CSS en 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
Construido en Promova — una plataforma de aprendizaje de idiomas que sirve a millones de usuarios.