Skip to content

Instantly share code, notes, and snippets.

@shakyShane
Created May 31, 2026 08:33
Show Gist options
  • Select an option

  • Save shakyShane/e5888ac601d2d76e77e8158545ec8598 to your computer and use it in GitHub Desktop.

Select an option

Save shakyShane/e5888ac601d2d76e77e8158545ec8598 to your computer and use it in GitHub Desktop.
nextssg inlineCss: why lazy islands still hoist CSS into noai and mobile HTML

nextssg inlineCss and why lazy islands do not lazy-load CSS

Context: DuckDuckGo apps/nextssg static export with experimental.inlineCss: true (next.config.ts). This document explains why mobile homepage work inflated the noai homepage (/en-USen-US.html) and why next/dynamic({ ssr: false }) does not keep CSS off the critical HTML path.

Measured on branch shane/mobile-promo-rebased-on-main vs main (33f9144adfd823), static export compare only:

Page gzip (main → branch) What changed
noai en-US.html 45.9 → 70.7 KiB (+24.8) ~78 KiB of new inline <style>; 100% matches mobile module selectors (mobile-atb-banner, mobile-home-promo, mobile-home-customizer, …)
/home/mobile +32.1 KiB gzip New UI + same CSS inlined into that HTML too
/home/desktop +10.1 KiB gzip Smaller shared/layout spillover

Two routes, one CSS merge behavior

Both pages live under src/app/[locale]/ and share the App Router shell (layout.tsx, Providers, globals.scss). With inlineCss: true, production HTML does not ship most CSS as cacheable <link rel="stylesheet"> files—it embeds merged CSS in <style> tags in each HTML file.

flowchart TB
  subgraph build["Turbopack / Next build (simplified)"]
    G["Client module graph + CSS modules"]
    M["Merge CSS modules into chunk(s)"]
    I["inlineCss: embed chunk CSS into each page HTML"]
    G --> M --> I
  end

  subgraph outputs["Static HTML outputs"]
    N["en-US.html — noai homepage"]
    MBL["en-US/home/mobile.html"]
  end

  I --> N
  I --> MBL
Loading

What we observed: CSS written for /home/mobile still appeared inside noai en-US.html even though that page never renders MobileHomePromo or the ATB banner. The bundler’s merge scope is wider than “components this route imports in React”—shared graph edges and inlineCss per-page inlining can pull in styles from other routes in the same app segment (same class of problem as the retired css-bench test pages, which added ~77 KiB to the noai document without the noai page importing them).

flowchart LR
  subgraph noai["Noai route `/[locale]/page.tsx`"]
    H["Header"]
    L["NoaiLogo"]
    S["SearchBox"]
    D["dynamic: BelowSearchBoxContent (ssr: false)"]
  end

  subgraph mobile["Mobile route `/[locale]/home/mobile`"]
    MP["MobileHomePromo (sync)"]
    ATB["mobile-atb-banner SCSS"]
    MC["dynamic: MobileHomeCustomizer"]
    FB["dynamic: feedback inside customizer"]
    SAD["dynamic: SAD modal"]
  end

  subgraph inlined_noai["en-US.html &lt;style&gt; on branch"]
    N1["noai: header, search, hero SCSS"]
    N2["⚠ also: mobile-atb-banner, mobile-home-promo, mobile-home-customizer rules"]
  end

  subgraph inlined_mobile["home/mobile.html &lt;style&gt;"]
    M1["mobile ATF + promo + ATB"]
    M2["often: customizer + feedback + modal SCSS too"]
  end

  noai -.->|"inlineCss merges across app graph"| inlined_noai
  mobile --> inlined_mobile
Loading

JavaScript lazy ≠ CSS lazy

Developers often assume next/dynamic({ ssr: false }) creates a below-the-fold boundary for everything (JS + CSS). In practice:

Asset Typical behavior with dynamic(ssr: false) First HTML byte
JS Separate chunk; loaded after hydration / interaction Not in main JS bundle
CSS modules for that tree Often merged into the same CSS chunk as sync siblings if the graph shares imports (especially design-system) Still inside <style> in HTML when inlineCss: true
sequenceDiagram
  participant User
  participant HTML as First HTML response
  participant JS as JS chunks
  participant CSS as CSS delivery

  User->>HTML: GET /home/mobile
  Note over HTML: inlineCss embeds merged CSS<br/>ATB + promo + customizer + feedback + …
  HTML-->>User: Large document + inline &lt;style&gt;

  User->>JS: Hydrate + load dynamic chunks later
  JS-->>User: Customizer / modal JS (deferred)

  Note over CSS,JS: CSS for lazy UI was already paid in HTML.<br/>dynamic() did not defer CSS.
Loading

Concrete example (verified experiment, May 2026 — see apps/nextssg/css-optimization-log.md H6):

  1. Baseline: Design system on sync paths (ATB, duckai button) and on a lazy path (LinkButton in tagline inside the mobile graph).
  2. Result: homepage-customize-feedback-module-scss-module selectors appeared inside home/mobile.html <style> (~124 KiB inline CSS). Feedback is behind dynamic() inside MobileHomeCustomizer; users still downloaded its CSS on first paint.
  3. Fix that worked: Remove @staticpages/design-system from the shared edges (e.g. tagline LinkButton → plain <a> + module SCSS). Then feedback CSS moved to an external .css chunk (~52 KiB) not inlined in HTML; inline CSS dropped to ~58 KiB.

Conclusion from that experiment: dynamic() alone does not keep CSS out of HTML. The merge trigger is any design-system import Turbopack can fold into the same CSS chunk as lazy islands—not whether the component mounts on first paint.


What “absent from the main chunk” actually means

JavaScript (main / route chunk)

Lazy components are absent from the initial JS payload:

flowchart TB
  subgraph first["First load — JS"]
    MAIN["Route + layout + sync components"]
  end

  subgraph later["After hydration / import()"]
    LAZY1["customizer.*.js"]
    LAZY2["feedback.*.js"]
    LAZY3["sad-modal.*.js"]
  end

  MAIN -->|"import()"| LAZY1
  LAZY1 --> LAZY2
Loading

Performance win: less JS to parse/compile before interactive (modulo chunk graph overhead).

CSS (with inlineCss: true)

CSS modules for lazy trees are not “absent from the main chunk” in the sense that matters for first byte:

flowchart TB
  subgraph wrong["Common mental model ❌"]
    W1["Main HTML: only ATF CSS"]
    W2["Later: fetch CSS when modal opens"]
  end

  subgraph actual["Actual behavior with inlineCss + shared DS graph ✅ measured"]
    A1["Main HTML &lt;style&gt;: ATF + lazy island CSS merged"]
    A2["Optional: extra .css files only if merge graph breaks DS coupling"]
  end
Loading
If this is true… First HTML When user opens customizer
Lazy component JS not in main chunk Smaller JS Chunk downloads
Lazy component CSS merged + inlined Larger HTML No extra CSS request—styles already there
Lazy CSS in external file (after DS boundary fix) Smaller HTML Browser may fetch .css with chunk (cacheable)

Content (DOM nodes for modals, feedback form, BTF promos) is absent until React runs—that part of “lazy” works. Styles for that content often are not absent from the document; they ride along in inline CSS, which hurts:

  • Cold cache / first visit: more HTML bytes before first paint (gzip helps but does not eliminate cost).
  • noai visitors: pay for mobile-only rules they never use (branch measurement: ~+78 KiB raw inline style on en-US.html).
  • Warm cache: external CSS files can be reused across navigations; one giant inline <style> per HTML file does not.

Side-by-side: noai vs mobile homepage (branch build)

block-beta
  columns 2

  block:noai:2
    columns 1
    NTitle["noai — en-US.html"]
    NRender["Renders: logo, search, header, BelowSearchBox (lazy JS)"]
    NPay["First byte pays for: noai SCSS + ⚠ mobile promo/ATB/customizer SCSS inlined"]
  end

  block:mobile:2
    columns 1
    MTitle["mobile — home/mobile.html"]
    MRender["Renders: hero, promo, ATB, lazy customizer/feedback/SAD"]
    MPay["First byte pays for: all of the above SCSS in one &lt;style&gt; blob (~120+ KiB pre-thin-ATF)"]
  end
Loading

Takeaway: Porting mobile homepage features is not isolated to /home/mobile for performance. Under inlineCss, CSS is a build-graph problem: sync vs dynamic() matters less than what imports share a mergeable CSS chunk (design-system is the documented tripwire).


Practical boundaries (from team experiments)

  1. Policy: Keep @staticpages/design-system out of the synchronous /home/mobile graph; allow only under known lazy roots (see apps/nextssg/README.md).
  2. Proven lever: Replace small ATF surfaces (tagline link, duckai button, ATB shell) with native HTML + module SCSS so lazy islands’ CSS can land in external chunks.
  3. Structural escape hatch: inlineCss: false + preload critical CSS (cold vs warm tradeoff documented in css-optimization-log.md).

Acceptance check after build:

# Feedback CSS must NOT be inside initial mobile HTML <style>
rg 'homepage-customize-feedback-module-scss-module' apps/nextssg/out/en-US/home/mobile.html

# Spot-check noai spillover
rg 'mobile-home-customizer-module-scss-module' apps/nextssg/out/en-US.html

References

  • apps/nextssg/next.config.tsexperimental.inlineCss: true
  • apps/nextssg/css-optimization-log.md — H6 lazy-island / design-system experiment
  • .github/workflows/nextssg-benchmark-pr-comment.yml + apps/benchmark/compare-static-export.mjs — PR static size compare (no Lighthouse)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment