Skip to main content
← Volver al blog
Next.jsCSSPerformanceTurbopackSCSS

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.

Publicado 8 de abril de 202612 min de lectura

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
// 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:

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

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

@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')
}

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:

InlineStyle.tsx
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'
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>
  )
}

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

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:

next.config.ts
// ❌ 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 processable

Los 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.

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)

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:

  1. Tienes un patrón registry/barrel — un archivo importa muchos componentes, pero solo unos pocos se renderizan por página
  2. Estás en Turbopack — Import Attributes son específicos de Turbopack
  3. Quieres control por componente — no un flag de todo o nada
  4. Tu SCSS es complejo — variables, mixins, breakpoints, anidación — todo soportado
  5. No puedes usar experimental.inlineCss — porque tienes páginas SSR o quieres control granular

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

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.