Skip to main content
← Назад до блогу
Next.jsCSSPerformanceTurbopackSCSS

Як я усунув 94 render-blocking CSS файли в Next.js 16 за допомогою погано задокументованої фічі Turbopack

Після днів спроб кожного підходу — від experimental.inlineCss до хаків з MutationObserver — я відкрив Turbopack Import Attributes, які вирішують проблему render-blocking CSS у Next.js App Router.

Опубліковано 8 квітня 2026 р.12 хв читання

Проблема

Наш застосунок (Promova) використовує Next.js 16 з Landing Builder — CMS-системою, яка збирає маркетингові сторінки з ~90 різних секційних компонентів (герої, FAQ, ціни, відгуки тощо). Архітектура використовує sectionRegistry.tsx, що мапить назви секцій на виклики 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
}

Одна лендінг-сторінка рендерить лише 5-8 секцій. Але Lighthouse показував:

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

Чому? Turbopack бачить усі 90 шляхів import() як досяжні й генерує <link rel="stylesheet"> для кожного SCSS-модуля. Навіть секції, які ніколи не рендеряться на сторінці, отримують свій CSS у <head>. Це підтверджена, очікувана поведінка Next.js App Router. CSS не розділяється для dynamic() імпортів із Server Components. Мейнтейнери вважають це свідомим компромісом для запобігання FOUC. Виправлення не планується.

Усе, що я спробував (і чому воно не працює)

Я витратив кілька днів, перебираючи кожен підхід, який зміг знайти. Ось повний список:

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

Пастка inlineCss

Next.js має прапорець experimental.inlineCss, який замінює всі <link rel="stylesheet"> на інлайн <style> теги. Звучить ідеально, правда?

Проблема: це все-або-нічого. Не можна увімкнути по маршруту. Якщо у вас є SSR (force-dynamic) сторінки, кожен запит перебудовує весь CSS інлайн. Ми спробували — наша headless CMS не витримала навантаження і впала. Щоб це працювало безпечно, 100% ваших сторінок мають бути force-static або ISR. З 20+ SSR-сторінками (авторизація, дашборди, динамічні сторінки) — це величезна міграція.

Відкриття: Turbopack Import Attributes

Копаючись у реліз-нотах Next.js 16.2, я знайшов погано задокументовану фічу: Turbopack Import Attributes. Вона дозволяє перевизначити вбудований пайплайн бандлера для конкретного імпорту за допомогою TC39 синтаксису with {}:

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

Це каже Turbopack: "Не обробляй цей імпорт як стилі. Пропусти його через мій кастомний лоадер і обробляй результат як JavaScript."

Це ключовий інсайт. Замість того, щоб Turbopack генерував <link rel="stylesheet">, який блокує рендеринг, наш лоадер компілює SCSS і експортує його як JS-рядок. Потім ми інʼєктуємо його як інлайн <style> тег прямо в компоненті. Результат: лише CSS для секцій, які реально рендеряться, потрапляє в HTML сторінки. Сторінка з 5 секціями отримує CSS для 5 секцій — не для 94.

Рішення

1. Кастомний Turbopack Loader

~70-рядковий Node.js скрипт як 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')
}

Що він робить: styles — та сама мапа скоупованих класів як у стандартних CSS Modules. cssText — скомпільований CSS як рядок.

2. Компонент InlineStyle

Використовує вбудований API React 19 <style href precedence> для автоматичної дедуплікації:

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

React 19 гарантує: однаковий href → лише один <style> у DOM. Якщо секція рендериться двічі на сторінці, CSS все одно інʼєктується один раз.

3. Міграція по компонентах (~6 рядків на секцію)

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

Файли .module.scss залишаються точно такими ж. Жодного переписування CSS. Stylelint, Prettier, підтримка IDE — все збережено.

Чому це краще за inlineCss: true

Ось критична різниця:

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

З inlineCss: true сторінка все одно отримує ВСІ 94 стилі інлайн — вони просто <style> теги замість <link>. HTML роздувається. З нашим підходом лише CSS, який реально рендериться, потрапляє в HTML. 5 секцій рендериться → 5 <style> тегів. Все.

Підводний камінь Turbopack: немає глобальних правил для .module.scss

Пастка, в яку я потрапив: може здатися, що можна додати правило Turbopack у next.config.ts для глобального застосування лоадера:

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

Не робіть цього. Вбудований пайплайн CSS-модулів Turbopack перехоплює .module.scss файли до застосування кастомних правил. Він створює EcmascriptCssModule ассет, який конфліктує з as: '*.js', спричиняючи:

FATAL PANIC: inner asset should be CSS processable

Атрибути with {} працюють, тому що вони інструктують Turbopack у місці імпорту повністю обійти пайплайн CSS-модулів. Немає способу зробити це глобально через конфіг.

Результати

Мігрували 127 секційних компонентів у Landing Builder. Виробничу збірку перевірено.

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)

Обмеження

  • with {} для кожного імпорту — багатослівно — кожен імпорт потребує 3 додаткові рядки. Немає способу застосувати глобально.
  • Тільки Turbopack — атрибути with {} не підтримуються Webpack. Для Webpack фолбеку імпорти повертаються до стандартних CSS Modules (cssText буде undefined).
  • Хешування імен класів — наш лоадер використовує інший алгоритм хешування ніж вбудований у Turbopack. Імена класів виглядають інакше в DevTools. Функціонально ідентичні.
  • Розмір HTML збільшується — CSS інлайн в HTML замість кешування як окремих файлів. Для статичних/ISR сторінок, що роздаються з CDN, це не проблема.

Коли це використовувати

Ця техніка найефективніша, коли:

  1. У вас є registry/barrel патерн — один файл імпортує багато компонентів, але лише кілька рендеряться на сторінці
  2. Ви на Turbopack — Import Attributes специфічні для Turbopack
  3. Вам потрібен контроль по компонентах — не прапорець все-або-нічого
  4. Ваш SCSS складний — змінні, міксіни, брейкпоінти, вкладеність — все підтримується
  5. Ви не можете використовувати experimental.inlineCss — бо маєте SSR-сторінки або хочете гранулярний контроль

Якщо вас торкнулась проблема render-blocking CSS у Next.js App Router — ви не самотні:

Основна проблема

  • #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 та проблеми

  • 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

Спільнота шукає рішення

Баги CSS у 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

Побудовано в Promova — платформі для вивчення мов, що обслуговує мільйони користувачів.