Gist-style summary of two structural experiments on the DuckDuckGo nextssg homepage rebuild.
Baseline context: static export +experimental.inlineCss: true, three HTML products (noai, mobile promo, desktop stub).
With one Next.js app and multiple routes under the same App Router tree (/en-US, /en-US/home/mobile, /en-US/home/desktop), production HTML for noai was picking up CSS meant for mobile—even when the noai page never renders mobile components.
Measured on the monolith + mobile promo work (vs main @ 33f9144):
| Entry | main (gzip) | Monolith + mobile promo | Δ |
|---|---|---|---|
noai en-US.html |
45.9 KiB | 70.7 KiB | +24.8 KiB |
mobile home/mobile.html |
46.9 KiB | 79.1 KiB | +32.2 KiB |
desktop home/desktop.html |
32.0 KiB | 42.1 KiB | +10.1 KiB |
dynamic(..., { ssr: false }) does not keep CSS out of the first HTML payload when inlineCss merges styles across the app graph. Mitigations inside a single app (thin ATF, no design-system on mobile sync path) helped but left ~+10 KiB mobile SCSS still visible in noai HTML.
Goal: each shipped HTML file should only inline CSS for what that URL actually paints—especially noai, which must stay lean.
Mobile promo added a large parallel tree (ATB, customizer, BTF, set-as-default, etc.) beside the existing noai homepage. In one repo folder it became hard to answer:
- What is allowed on the mobile critical render path?
- What assets are noai-only vs mobile-only?
- What runs in which Next build?
Goal: boundaries that match deploy surfaces, not “everything imports everything via @/.”
Nginx still expects a single static tree under /static-pages/turbo/ with paths like en-US.html, en-US/home/mobile.html. So however we split builds, we still merge out/ directories before release. Merge is a packaging step, not a fourth product.
| Idea | Why we passed |
|---|---|
pageExtensions per surface (e.g. .noai.tsx) |
Awkward dev UX, easy to mis-route, still one CSS graph unless combined with more splitting. |
Materialized / generated app/ trees |
Duplication and tooling complexity; hard to review diffs. |
| Only thin-ATF / lazy-boundary policy in monolith | Necessary but not sufficient; ~+10 KiB mobile spill remained on noai after ATF work. |
| Separate repos | Out of scope; wanted a Turborepo package/app split. |
Run three independent next builds (separate CSS graphs) but keep one fat shared package (@staticpages/nextssg-homepage) holding almost all UI, hooks, messages, and styles. Each surface app owns only src/app/ routes and points @/* at the homepage package.
apps/nextssg-noai/ ──┐
apps/nextssg-mobile/ ──┼──► packages/nextssg-homepage/ (components, hooks, styles, messages, …)
apps/nextssg-desktop/ ──┘
apps/nextssg/ meta workspace: merge out/ + e2e serve
| Problem | Effect |
|---|---|
| Cross-entry CSS | Strong fix. Separate builds → noai HTML no longer contained mobile-atb-banner, mobile-critical-atf-primitives, customizer modules, etc. |
| Ownership | Weak. Code still lives in one package; apps look thin but dependency graph is still shared at source level. |
| Deploy | Merge step copies three out/ dirs into one tree. |
Pros
- Proven isolation of
inlineCss/ webpack CSS chunks per product. - Minimal route-level duplication; fast to split from monolith.
- Shared i18n, header, search, mobile promo in one place—familiar monorepo pattern.
Cons
- Misleading structure: three “apps” that are mostly aliases into the same blob.
- Easy to reintroduce spill at source level (any shared import pulls modules into multiple graphs depending on who imports whom).
- Noai logo, mobile ATB, and promo logic still coexist in one package—reviews require discipline, not folder boundaries.
transpilePackages: [@staticpages/nextssg-homepage]on every app—surface apps are not real units for lint, tests, or assets.
npx turbo run build \
--filter=nextssg-noai --filter=nextssg-mobile --filter=nextssg-desktop \
--filter=nextssg # merge into apps/nextssg/out/Playwright lived in apps/nextssg-e2e, serving merged output—same as monolith e2e mindset.
After split, noai-only app build landed around ~50.5 KiB (+4.6 KiB vs main)—mobile selectors gone from noai inline CSS, with a smaller residual gap vs historical main (shared shell / layout tax, not full mobile promo spill).
Treat each surface as a real deliverable: its own src/, components, hooks, assets, and surface-only SCSS. Extract a small @staticpages/nextssg-shared (header, side menu, locale shell, providers, i18n, globals, shared utils). Imports are explicit: @/ = this app only, @shared/ = shared shell.
packages/nextssg-shared/ shell + i18n + globals (small)
apps/nextssg-noai/src/ noai logo, search box, download CTAs, noai assets
apps/nextssg-mobile/src/ mobile promo, ATB, customizer, ATF primitives, mobile assets
apps/nextssg-desktop/src/ desktop stub
out/nextssg-merged/ merge script only (not a Next app)
Removed the fake fourth app (apps/nextssg); merge and serve are root scripts (npm run merge-nextssg, npm run serve:nextssg).
| Problem | Effect |
|---|---|
| Cross-entry CSS | Same graph isolation as A (still three builds). Ruthless split does not change Next’s per-app CSS graph; it prevents accidental cross-imports at authoring time. |
| Ownership | Strong. Opening apps/nextssg-mobile shows only mobile concerns; noai logo does not exist in mobile tree. |
| Deploy | Same merge into out/nextssg-merged/ → npm run merge-output → static-pages/out/turbo/. |
Pros
- Matches how teams think about products (noai vs mobile vs desktop).
- Surface-specific tests, assets, and ATF policy can live with the surface.
- Per-app serve already works (
nextssg-mobile→:5051, ownout/). - Shared package stays small and stable—easier to reason about what must stay in sync (header, pixels, locale layout).
Cons
- Duplication where surfaces share behavior (e.g. autocomplete + suggestions list copied for mobile search toggle vs noai search box).
- Two import aliases (
@/,@shared/) and migration churn. public/still symlinked to shared fonts/favicons (pragmatic; not fully ruthless for static files).- Playwright not yet colocated—still centralized in
nextssg-e2eagainst merged output (documented gap). - Mobile unit tests (
__tests__-homepage) need Jest wired per app.
npm run build:nextssg # three builds + merge → out/nextssg-merged/
npm run serve:nextssg # :5050 merged (CI / deploy parity)
npm run dev -w nextssg-mobile # :4001 — mobile only, no merge
npm run start -w nextssg-mobile # :5051 — serve mobile out/ only| Dimension | A: Three apps + homepage blob | B: Ruthless split + nextssg-shared |
|---|---|---|
| Primary goal | Fix inlineCss cross-entry bleed |
Fix bleed and ownership / reviewability |
| # of Next builds | 3 | 3 (unchanged) |
| Shared code | One large nextssg-homepage |
Small nextssg-shared only |
| Surface code location | Homepage package | Each app’s src/ |
| Import model | @/* → homepage |
@/* local, @shared/* shell |
| noai logo in mobile tree | Possible (same package) | No (not in mobile app) |
| CSS isolation mechanism | Separate Next apps | Separate Next apps (+ stricter imports) |
| Merge / deploy artifact | apps/nextssg/out (4th workspace) |
out/nextssg-merged/ (root script) |
| Dev iteration | Edit homepage package | Edit the surface app you care about |
| Cost to port from static-pages | Lower (one target package) | Higher (split files + rewrite imports) |
| Risk of graph regressions | Medium (shared package imports) | Lower for surface-only features |
| E2E layout | Central nextssg-e2e, merged serve |
Same today; intended: per-app Playwright |
All figures gzip on en-US.html unless noted; baseline main ≈ 45.9 KiB.
| Stage | noai gzip | Mobile spill in noai <style>? |
|---|---|---|
| Monolith + mobile promo + DS on sync path | 70.7 KiB (+24.8) | Yes (ATB, customizer, feedback, BTF, DS link-button, …) |
| Monolith + thin-ATF policy | 56.3 KiB (+10.3) | Partial (e.g. mobile-atb-banner still present) |
| Experiment A — three apps, shared homepage pkg | ~50.5 KiB (+4.6) | No mobile promo modules in noai HTML |
| Experiment B — ruthless split (same 3 builds) | Same class as A | No (graph isolation unchanged; authoring stricter) |
Takeaway: The big win for noai HTML weight came from multiple Next builds, not from renaming packages. Experiment B mainly improves how humans and CI reason about the codebase; it does not replace the need for sync-path / DS discipline on mobile.
| Concern | Monolith | Experiment A | Experiment B (current) |
|---|---|---|---|
| Dev | One next dev |
Per-app ports 4000/4001/4002 | Same |
| Prod-like serve, one surface | N/A | Possible but undocumented | npm run start -w nextssg-{surface} |
| Prod-like serve, merged | Single out/ |
nextssg workspace start |
npm run serve:nextssg |
| Playwright | In monolith / static-pages pattern | nextssg-e2e → merged |
Still nextssg-e2e → merged; recommended: colocate *.mobile.spec.ts under nextssg-mobile |
| Unit tests | Jest in app | Jest on homepage package | Shared package + orphaned mobile __tests__-homepage (wire per app) |
Principle: A deliverable unit should build → serve its own out/ → test its own routes without merge. Merge remains a release packaging step, not a daily dev loop.
Prefer Experiment A (homepage blob) if:
- The team optimizes for fast porting from
static-pagesand accepts one shared import graph. - Code review discipline + grep gates on HTML are enough guardrails.
- Ownership blur is acceptable short term.
Prefer Experiment B (ruthless split) if:
- Mobile and noai are long-lived parallel products with different perf budgets and feature sets.
- You want folder boundaries to match nginx surfaces and on-call ownership.
- You plan per-surface CI, benchmarks, and Playwright without a permanent “e2e hub” app.
Non-negotiable for both: three static exports + merge for deploy; mobile sync-path rules (no design-system on /home/mobile CRP); post-build checks on all three HTML files, not only the route you edited.
- Colocate Playwright under
nextssg-mobile/nextssg-noai; shrinknextssg-e2eto cross-surface or merged-smoke only. - Wire Jest per surface app; relocate
__tests__-homepage. - Decide on shared vs duplicated search/autocomplete between noai and mobile toggle.
- Optional: per-surface
public/instead of symlink to shared fonts.
Experiment A proved that three Next builds fix noai/mobile CSS cross-contamination; Experiment B keeps that win and adds honest package boundaries so each surface is a deliverable unit—at the cost of duplication, migration, and finishing serve/test colocation.