Build performance — analysis + the multi-zone split plan
Analysis 2026-07-27. The app is 443 page.tsx routes (214 under /dashboard), 519 total route entries, 510 dynamic / 9 static. Build time is dominated by compiling the route graph + (until now) an inline tsc + ESLint pass, with no build caching.
What the build spends time on (measured)
- Compiling 443 route entries + shared chunks (~50s–2min for webpack alone; more on a loaded box).
tsc+ ESLint run INSIDEnext build— noignoreBuildErrors/ignoreDuringBuilds, and there was no separate CI typecheck gate. On a 440+-route app the "checking validity of types / linting" phase is a large, serial slice of every deploy build.- No Turborepo /
.next/cachereuse — every build is from scratch; a one-line marketing-copy change rebuilds all 519 routes. - Heavy WebXR/three stack (
three+@react-three/fiber/drei/xr) in the graph for ~a dozen VR routes (already needs webpack-alias wrestling innext.config.js). - Not static-generation: only 9 static pages, ~8 build-time backend-fetch stalls. This is a compile-surface problem, not a data-fetch one.
Tier 1 — quick wins ✅ SHIPPED (2026-07-27)
- Moved
tsc+ ESLint out of the deploy build —apps/frontend/next.config.jssetstypescript.ignoreBuildErrors+eslint.ignoreDuringBuilds; the safety moved earlier + parallel via thecheck-frontendGitHub Actions job (npm run checkon every frontend PR). Errors block the PR, not the deploy.next buildstill runs the prerender pass, so Suspense / dynamic-paramslandmines are still caught. See CLAUDE.md "Preflight". - TODO (Tier 1b): Turborepo +
.next/cachepersistence in CI so unchanged workspaces/pages skip rebuild. Pairs with the split below (Turbo caches unchanged zones).
Tier 2 — the multi-zone split (the durable fix)
Goal: stop rebuilding the whole app for every change. Split apps/frontend into independently-built Next apps so each build is a fraction of the routes and they build in parallel; a marketing-copy change never rebuilds the 214-route dashboard.
Proposed zones
| Zone | Routes (approx) | Volatility | Notes |
|---|---|---|---|
apps/marketing | ~150 public (/, /features, product landings, /compare, /blog, /pricing, /sitemap, /ethos, 404 …) | high (copy, painterly bands) | Fast build; the surface we touch most. |
apps/app (rename of today's apps/frontend) | ~370 authed (/dashboard/**, compose, stream, studios) | medium | The product. |
apps/admin (optional 3rd) | /dashboard/admin/** (~30) | high, super-admin only | Carve out so admin churn never blocks user deploys. |
How to wire it
- Next Multi-Zones: each zone is its own Next app with a distinct
basePath/assetPrefix; a top-levelrewrites()(or a Vercel project with rewrites) stitches paths so the domain is seamless. Cross-zone links are plain<a href>(not<Link>), since they cross app boundaries. - Shared code → a workspace package (
packages/uiorpackages/shared): the design system,Navbar/SiteFooter,nav-catalog.ts,HighlightBand,AppLauncher,lib/api, supabase client, types. Both zones depend on it. This extraction is the bulk of the work and should land first, incrementally, while everything still lives in one app. - Auth across zones: Supabase session cookie must be scoped to the apex domain (
.hivejournal.com) so a session set inapps/appis readable inapps/marketing(for the logged-in→/dashboardredirect) and vice versa. Verify cookiedomain/sameSite. - Env + deploy: one Vercel project per zone (or one project with multiple builds), each with its own
deploy-*.ymltriggered on that zone'spaths:. Turborepo then caches unchanged zones → a marketing change rebuilds onlyapps/marketing.
Sequencing (each step ships independently, app stays whole until the last)
- Extract shared code into
packages/shared(design system, Navbar/footer, nav-catalog, api, supabase). No behavior change; just move + re-import. Biggest, safest chunk. - Add Turborepo (
turbo.json) acrossapps/*+packages/*+ backend; wire CI to Turbo cache. Immediate incremental-build win even before splitting. - Stand up
apps/marketingas a second Next app consumingpackages/shared; move the ~150 public routes into it; add rewrites so the domain is seamless; verify the logged-in→dashboard redirect + cookie domain. - (Optional) carve
apps/admin. - Delete the moved routes from the original app; it becomes
apps/app.
Cost / risk
- Effort: days, front-loaded on step 1 (shared-package extraction) — mechanical but broad.
- Risk: cross-zone auth/cookie edge cases; cross-zone navigation must use
<a>; duplicate shared deps if the package boundary is sloppy. All manageable, none irreversible. - Payoff: each build ≈ half (or a third) the routes, in parallel, cached — plus deploy independence (marketing ships without waiting on the app).
Progress + a key refinement (2026-07-27)
Foundation shipped + fully validated in CI and Vercel (#1633–#1636):
@hivejournal/shared— platform-agnostic (types, utils, nav-catalog), prebuiltdist, also used by mobile. Added apreparehook sodistbuilds on everynpm ci(dist is gitignored) — validated against a clean install + Vercel.packages/ui— web/Next components consumed from source viatranspilePackages(so'use client',next/image, JSX work).HighlightBand(server component) +SiteMapExplorer(client component) both proven through it.check-frontendCI widened topackages/**.
So both package patterns, server + client components, the prepare hook, and the gate are all de-risked.
The coupling finding that reframes step 3. The clean, self-contained components are extracted. Everything else worth sharing converges on the heavy app chrome: LandingShell → imports Navbar + SiteFooter; SiteFooter → HearThisPage → narration → api/supabase; Navbar is the 2,286-line everything-widget. Trying to hoist that chrome into a package drags the whole app infra with it.
The refinement: apps/marketing does not want the app's chrome. A public marketing page needs a lean, purpose-built Navbar + Footer (logged-out CTAs, footer links — no 25-icon bar, no HearThisPage, no auth context), and it reuses only the genuinely-shared UI already in packages/ui (painterly bands, sitemap) + packages/shared (nav-catalog). So step 3 is "scaffold a lean marketing app and move the ~150 public routes into it," not "extract Navbar/SiteFooter into a package." That's a smaller, cleaner target than it first looked — the shared surface is small; the chrome is rebuilt lean.
Revised step 3: scaffold apps/marketing (new Next app, lean chrome, transpilePackages: ['@hivejournal/ui']) → prove the multi-zone rewrite + cookie-domain auth with one moved page → then batch-move the rest. A dedicated effort (second Next app + multi-zone routing is fiddly), best on fresh context.
Migration runbook (the dedicated effort)
The pattern is proven end-to-end (apps/marketing builds in ~2s, has lean chrome, and serves a real reused route at /sitemap). What's left is a migration, and it has one non-obvious ordering rule:
Wire the routing switch and move routes TOGETHER, not routes-first. Moving a route into
apps/marketingbefore the multi-zone routing exists just creates a duplicate, drift-prone, inert copy (the frontend still serves it in prod). Value appears only when the zone switch flips traffic. So each route moves and goes live in the same step, behind a preview.
Order of operations (each step ships behind a Vercel preview, reversible):
- Multi-zone routing first (production-touching — do deliberately, watch previews). Stand up
apps/marketingas its own Vercel project. On the primary project, add rewrites so a fixed set of public paths (start with just/sitemap) proxy to the marketing zone; everything else stays on the app. Verify the domain is seamless on a preview before promoting. - Cookie-domain auth. Confirm the Supabase session cookie is scoped to
.hivejournal.comso a session set in either zone is readable in the other (needed for the logged-in/→/dashboardredirect + logged-in nav state). Test cross-zone before moving auth-aware pages. - Migrate routes in waves, cheapest-first, adding each to the rewrite set as it moves:
- Wave A (self-contained): pages whose only app deps are chrome (
Navbar/SiteFooter, provided by the marketing layout) + already-shared UI. Strip the chrome imports, drop the page in, add its path to the rewrites. (/privacy,/terms,/about-that,/docs,/examples…) - Wave B (needs a shared bit): pages importing one more component/lib — move that dep to
packages/ui/packages/sharedfirst (mechanical, same as HighlightBand), then the page. - Wave C (landing pages): build the lean marketing equivalents of
LandingShell/HeroGlowinapps/marketing(they wrap the heavy chrome today), then move the/for-*+ product landings.
- Wave A (self-contained): pages whose only app deps are chrome (
- Deploy workflow for
apps/marketing(mirrordeploy-frontend.yml, path-filtered toapps/marketing/**+packages/**). - Delete the migrated routes from
apps/frontendonly once the zone is serving them in prod, so there's never a gap.
Per-route recipe (Wave A): copy the page's inner content into apps/marketing/src/app/<route>/page.tsx; delete its <Navbar/>/<SiteFooter/> wrapper (the marketing layout provides lean chrome); repoint any shared imports to @hivejournal/ui/@hivejournal/shared; cd apps/marketing && npm run build (the check-marketing gate runs this in CI); add the path to the rewrite set; verify on a preview.
Tier 3 — compile-surface trims (modest, do anytime)
- Ensure the WebXR/three stack is fully
next/dynamicssr:falseon the VR routes sothree/fiber/drei/xrstays out of the shared/server compile graph for non-VR builds. - Prune dead one-offs (
/for-ben, stale experimental routes) — marginal but real at 443 routes.
Recommendation
Tier 1 is shipped. Measure a few deploys. If builds are still too slow, do Tier 2 in the sequenced order — the shared-package extraction (step 1) + Turborepo (step 2) deliver most of the win and de-risk the actual zone split.