Next.js 16-да рендерлеуді бөгейтін 94 CSS файлын нашар құжатталған Turbopack мүмкіндігімен қалай жойдым
experimental.inlineCss-тен MutationObserver хактарына дейін әрбір тәсілді сынап көрген бірнеше күннен кейін мен Next.js App Router-дегі рендерлеуді бөгейтін CSS мәселесін шешетін Turbopack Import Attributes мүмкіндігін таптым.
Мәселе
Біздің қосымша (Promova) Next.js 16-ны Landing Builder — маркетингтік беттерді ~90 түрлі секция компоненттерінен (hero-лар, FAQ-тар, баға, пікірлер т.б.) құрастыратын CMS негізіндегі жүйемен бірге қолданады. Архитектура секция атауларын 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() жолын қолжетімді деп санайды және әрбір SCSS модулі үшін <link rel="stylesheet"> жасайды. Тіпті бетте ешқашан рендерленбейтін секциялардың да CSS-і <head> ішіне енгізіледі. Бұл — Next.js App Router-дің расталған, күтілетін әрекеті. CSS Server Components-тан келетін dynamic() импорттары үшін кодқа бөлінбейді. Мейнтейнерлер мұны FOUC-тың алдын алу үшін жасалған әдейі ымыраға келу деп есептейді. Түзету жоспарланбаған.
Мен сынап көрген барлық әдіс (және неге сәтсіз аяқталғаны)
Мен таба алатын әрбір тәсілді қарастырып, бірнеше күн жұмсадым. Міне толық тізім:
| 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 тұзағы
Next.js-те барлық <link rel="stylesheet"> тегтерін inline <style> тегтерімен ауыстыратын experimental.inlineCss жалауы бар. Тамаша естіледі, солай ма?
Мәселе мынада: бұл бәрі не ештеңе. Оны маршрут бойынша қосуға болмайды. Егер сізде SSR (force-dynamic) беттер болса, әрбір сұраныс барлық CSS-ті inline түрде қайта құрады. Біз оны сынап көрдік — біздің 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-ке былай дейді: "Бұл импортты стиль кестесі ретінде өңдеме. Оны менің арнайы loader-ым арқылы өткізіп, нәтижесін JavaScript ретінде қарастыр."
Бұл — түйінді түсінік. Turbopack рендерлеуді бөгейтін <link rel="stylesheet"> жасаудың орнына, біздің loader SCSS-ті компиляциялап, оны JS жолы ретінде экспорттайды. Содан кейін біз оны компоненттің ішіне тікелей inline <style> тегі ретінде енгіземіз. Нәтиже: бетке нақты рендерленетін секциялардың CSS-і ғана бет HTML-іне түседі. 5 секциясы бар бет 94 емес, 5 секцияға арналған CSS алады.
Шешім
1. Арнайы Turbopack Loader
yarn workspace пакеті ретіндегі ~70 жолдық Node.js скрипті (@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')
}Ол не істейді: styles — стандартты CSS Modules-тегідей аймақталған класс атауларының картасы. cssText — жол ретіндегі компиляцияланған CSS.
2. InlineStyle компоненті
Автоматты дедупликация үшін React 19-дың кірістірілген <style href precedence> API-ін пайдаланады:
export function InlineStyle({ css, id }: { css: string; id: string }) {
return (
<style href={id} precedence="default">
{css}
</style>
)
}React 19 кепілдік береді: бірдей href → DOM-да тек бір <style>. Егер секция бетте екі рет рендерленсе, CSS бәрібір бір рет қана енгізіледі.
3. Компонент бойынша миграция (секциясына ~6 жол)
// 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 файлдары дәл сол күйінде қалады. CSS-ті қайта жазу жоқ. Stylelint, Prettier, IDE қолдауы — бәрі сақталады.
Неге бұл inlineCss: true-ден жақсы
Міне басты айырмашылық:
| <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) |
inlineCss: true кезінде бет бәрібір БАРЛЫҚ 94 стиль кестесін inline түрде алады — олар жай ғана <link> тегтерінің орнына <style> тегтері. HTML ісінеді. Біздің тәсілмен тек нақты рендерленетін CSS қана HTML-ге түседі. 5 секция рендерленді → 5 <style> тегі. Болды.
Turbopack тұзағы: .module.scss үшін глобалды ережелер жоқ
Мен түскен бір тұзақ: loader-ды глобалды түрде қолдану үшін next.config.ts ішіне Turbopack ережесін қосуға болады деп ойлауыңыз мүмкін:
// ❌ THIS CAUSES A FATAL PANIC
turbopack: {
rules: {
'**/components/**/*.module.scss': {
loaders: ['@promova/scss-to-inline-loader'],
as: '*.js'
}
}
}Мұны істемеңіз. Turbopack-тің кірістірілген CSS модуль конвейері арнайы ережелер қолданылмас бұрын .module.scss файлдарын ұстап алады. Ол as: '*.js' шығысымен қайшы келетін EcmascriptCssModule активін жасайды, бұл мынаған әкеледі:
FATAL PANIC: inner asset should be CSS processablewith {} атрибуттары жұмыс істейді, өйткені олар Turbopack-ке импорт орнында CSS модуль конвейерін толығымен айналып өтуді тапсырады. Мұны конфигурация арқылы глобалды түрде жасаудың жолы жоқ.
Нәтижелер
Landing Builder бойынша 127 секция компоненті көшірілді. Өндірістік құрастыру тексерілді.
| 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) |
Шектеулер
- Импорт сайын
with {}ұзақ-сонар — әрбір импортқа 3 қосымша жол қажет. Оны глобалды түрде қолданудың жолы жоқ (жоғарыдағы тұзақты қараңыз). - Тек Turbopack —
with {}атрибуттарын Webpack қолдамайды. Егер Webpack қосалқы нұсқасы қажет болса, импорттар стандартты CSS Modules-ке қайтады (cssTextundefinedболады). - Класс атауларын хештеу — біздің loader Turbopack-тің кірістірілгенінен өзге хештеу алгоритмін қолданады. Класс атаулары DevTools-та басқаша көрінеді. Функционалды тұрғыдан бірдей.
- HTML өлшемі ұлғаяды — CSS бөлек файлдар ретінде кэштелудің орнына HTML ішінде inline болады. CDN-нен берілетін статикалық/ISR беттер үшін бұл мәселе емес.
Мұны қашан қолдану керек
Бұл әдіс мына жағдайларда неғұрлым тиімді:
- Сізде registry/barrel үлгісі бар — бір файл көптеген компоненттерді импорттайды, бірақ бетте тек бірнешеуі ғана рендерленеді
- Сіз Turbopack-тесіз — Import Attributes Turbopack-ке тән
- Сізге компонент бойынша бақылау керек — бәрі-не-ештеңе жалауы емес
- Сіздің SCSS күрделі — айнымалылар, mixin-дер, breakpoint-тер, ұялау — бәрі қолдау табады
- Сіз
experimental.inlineCss-ті қолдана алмайсыз — өйткені сізде SSR беттер бар немесе егжей-тегжейлі бақылау қалайсыз
Қатысты GitHub Issues
Егер сіз Next.js App Router-дегі рендерлеуді бөгейтін CSS-тен зардап шексеңіз — сіз жалғыз емессіз:
Негізгі мәселе
- #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
Қауымдастық шешім іздеуде
- 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 қателері
- #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-да жасалған. Егер сіз дәл осындай CSS мәселесімен күресіп жатсаңыз, жоғарыдағы GitHub issue-ларында осы мақалаға сілтеме жасаудан тартынбаңыз.