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-US → en-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 (33f9144 → adfd823), 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 |
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
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 <style> 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 <style>"]
M1["mobile ATF + promo + ATB"]
M2["often: customizer + feedback + modal SCSS too"]
end
noai -.->|"inlineCss merges across app graph"| inlined_noai
mobile --> inlined_mobile
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 <style>
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.
Concrete example (verified experiment, May 2026 — see apps/nextssg/css-optimization-log.md H6):
- Baseline: Design system on sync paths (ATB, duckai button) and on a lazy path (
LinkButtonin tagline inside the mobile graph). - Result:
homepage-customize-feedback-module-scss-moduleselectors appeared insidehome/mobile.html<style>(~124 KiB inline CSS). Feedback is behinddynamic()insideMobileHomeCustomizer; users still downloaded its CSS on first paint. - Fix that worked: Remove
@staticpages/design-systemfrom the shared edges (e.g. taglineLinkButton→ plain<a>+ module SCSS). Then feedback CSS moved to an external.csschunk (~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.
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
Performance win: less JS to parse/compile before interactive (modulo chunk graph overhead).
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 <style>: ATF + lazy island CSS merged"]
A2["Optional: extra .css files only if merge graph breaks DS coupling"]
end
| 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.
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 <style> blob (~120+ KiB pre-thin-ATF)"]
end
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).
- Policy: Keep
@staticpages/design-systemout of the synchronous/home/mobilegraph; allow only under known lazy roots (seeapps/nextssg/README.md). - 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.
- Structural escape hatch:
inlineCss: false+ preload critical CSS (cold vs warm tradeoff documented incss-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.htmlapps/nextssg/next.config.ts—experimental.inlineCss: trueapps/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)