Skip to content

Instantly share code, notes, and snippets.

@shakyShane
Created May 31, 2026 11:10
Show Gist options
  • Select an option

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

Select an option

Save shakyShane/b5a7094f30ed48b528b9ba9be3a106ac to your computer and use it in GitHub Desktop.
nextssg architecture experiments: three-app CSS split vs ruthless monorepo split (comparison)

nextssg architecture experiments — comparison

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


What we are trying to solve

1. Cross-entry CSS bleed (performance)

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.

2. Ownership and mental model (engineering)

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 @/.”

3. Deploy shape (constraint, not optional)

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.


Approaches we did not take (for context)

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.

Experiment A — Three Next apps + shared homepage blob

Idea

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

How it addresses the problems

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.

Tradeoffs

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.

Build / serve / test (as built)

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.

Measured outcome (noai gzip, vs main 45.9 KiB)

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


Experiment B — Ruthless monorepo split

Idea

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

How it addresses the problems

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-outputstatic-pages/out/turbo/.

Tradeoffs

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, own out/).
  • 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-e2e against merged output (documented gap).
  • Mobile unit tests (__tests__-homepage) need Jest wired per app.

Build / serve / test (current)

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

Side-by-side comparison

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

Results summary (performance)

All figures gzip on en-US.html unless noted; baseline main45.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.


Serving and testing (evolution)

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.


When to choose which

Prefer Experiment A (homepage blob) if:

  • The team optimizes for fast porting from static-pages and 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.


Open work (Experiment B)

  1. Colocate Playwright under nextssg-mobile / nextssg-noai; shrink nextssg-e2e to cross-surface or merged-smoke only.
  2. Wire Jest per surface app; relocate __tests__-homepage.
  3. Decide on shared vs duplicated search/autocomplete between noai and mobile toggle.
  4. Optional: per-surface public/ instead of symlink to shared fonts.

One-line summary

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment