Hvordan jeg eliminerte 94 render-blokkerende CSS-filer i Next.js 16 med en dårlig dokumentert Turbopack-funksjon
Etter dager med å prøve hver tilnærming — fra experimental.inlineCss til MutationObserver-hacks — oppdaget jeg Turbopack Import Attributes som løser problemet med render-blokkerende CSS i Next.js App Router.
Problemet
Appen vår (Promova) bruker Next.js 16 med en Landing Builder — et CMS-drevet system som setter sammen markedsføringssider fra ~90 forskjellige seksjonskomponenter. Arkitekturen bruker en sectionRegistry.tsx som mapper seksjonsnavn til next/dynamic()-kall:
// 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
}En enkelt landingsside rendrer bare 5-8 seksjoner. Men Lighthouse viste:
Eliminate render-blocking resources
94 CSS resources (~330 KB)
Potential savings: 5,440 msHvorfor? Turbopack ser alle 90 import()-stier som oppnåelige og genererer <link rel="stylesheet"> for hver SCSS-modul. Selv seksjoner som aldri rendres på siden får sin CSS injisert i <head>. Dette er en bekreftet, forventet oppførsel i Next.js App Router. Ingen fix er planlagt.
Alt jeg prøvde (og hvorfor det feilet)
Jeg brukte dager på å gå gjennom hver tilnærming jeg kunne finne. Her er den fullstendige listen:
| 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 |
inlineCss-fellen
Next.js har et experimental.inlineCss-flagg som erstatter alle <link rel="stylesheet"> med inline <style>-tagger. Høres perfekt ut, ikke sant?
Problemet: det er alt eller ingenting. Du kan ikke aktivere det per rute. Hvis du har SSR-sider (force-dynamic), bygger hver forespørsel om all CSS inline. Vi prøvde — vår headless CMS tålte ikke lasten.
Oppdagelsen: Turbopack Import Attributes
Ved å grave i Next.js 16.2-utgivelsesnotater fant jeg en dårlig dokumentert funksjon: Turbopack Import Attributes. Den lar deg overstyre den innebygde bundler-pipelinen for en spesifikk import med TC39 with {}-syntaks:
import { cssText, styles } from './hero.module.scss' with {
turbopackLoader: '@promova/scss-to-inline-loader',
turbopackAs: '*.js'
}Dette forteller Turbopack: "Ikke behandle denne importen som et stilark. Kjør den gjennom min egendefinerte loader og behandle utdataen som JavaScript."
Dette er den avgjørende innsikten. I stedet for at Turbopack genererer et <link rel="stylesheet"> som blokkerer rendering, kompilerer vår loader SCSS og eksporterer det som en JS-streng. Resultat: bare CSS for seksjoner som faktisk rendres havner i sidens HTML.
Løsningen
1. Egendefinert Turbopack Loader
Et ~70-linjers Node.js-skript som yarn workspace-pakke (@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')
}Hva den gjør: styles — det samme scopede klassenavnkartet som standard CSS Modules. cssText — kompilert CSS som streng.
2. InlineStyle-komponent
Bruker React 19s innebygde <style href precedence>-API for automatisk deduplisering:
export function InlineStyle({ css, id }: { css: string; id: string }) {
return (
<style href={id} precedence="default">
{css}
</style>
)
}React 19 garanterer: samme href → bare én <style> i DOM.
3. Migrering per komponent (~6 linjer per seksjon)
// 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>
)
}.module.scss-filene forblir nøyaktig de samme. Ingen CSS-omskriving.
Hvorfor dette er bedre enn inlineCss: true
Her er den kritiske forskjellen:
| <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) |
Med inlineCss: true får en side fortsatt ALLE 94 stilark inline. Med vår tilnærming havner bare CSS som faktisk rendres i HTML-en.
Turbopack-fellen: ingen globale regler for .module.scss
En felle jeg gikk i: du kan tro at du kan legge til en Turbopack-regel i next.config.ts for å bruke loaderen globalt:
// ❌ THIS CAUSES A FATAL PANIC
turbopack: {
rules: {
'**/components/**/*.module.scss': {
loaders: ['@promova/scss-to-inline-loader'],
as: '*.js'
}
}
}Ikke gjør dette. Turbopacks innebygde CSS-modul-pipeline fanger .module.scss-filer før egendefinerte regler brukes, noe som forårsaker:
FATAL PANIC: inner asset should be CSS processablewith {}-attributter fungerer fordi de instruerer Turbopack på importstedet om å omgå CSS-modul-pipelinen fullstendig.
Resultater
127 seksjonskomponenter migrert i Landing Builder. Produksjonsbygg verifisert.
| 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) |
Begrensninger
with {}per import er ordrig — hver import trenger 3 ekstra linjer.- Bare Turbopack —
with {}-attributter støttes ikke av Webpack. - Klassenavnhashing — vår loader bruker en annen hashingsalgoritme enn Turbopacks innebygde.
- HTML-størrelse øker — CSS er inline i HTML i stedet for cachede separate filer.
Når du bør bruke dette
Denne teknikken er mest effektiv når:
- Du har et registry/barrel-mønster — én fil importerer mange komponenter, men bare noen få rendres per side
- Du bruker Turbopack — Import Attributes er Turbopack-spesifikke
- Du vil ha kontroll per komponent — ikke et alt-eller-ingenting-flagg
- SCSS-en din er kompleks — variabler, mixins, breakpoints, nesting — alt støttet
- Du ikke kan bruke
experimental.inlineCss— fordi du har SSR-sider eller vil ha granulær kontroll
Relaterte GitHub Issues
Hvis du er berørt av render-blokkerende CSS i Next.js App Router — du er ikke alene:
Kjerneproblemet
- #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
inlineCss-funksjonen & problemer
- 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
Fellesskapet søker løsninger
- 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)
Turbopack CSS-feil
- #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
Bygget hos Promova — en språklæringsplattform som betjener millioner av brukere.