Migrate apps/web to Next.js 16 Cache Components with instant navigation - #80
Conversation
Turn on the cacheComponents flag and restructure the root layout so the <html>/<body> shell prerenders while the request-dependent provider tree (locale, auth, feature flags) streams under Suspense.
cacheComponents rejects dynamic/runtime route segment configs, so drop the redundant runtime="nodejs"/"edge" and force-dynamic exports from API routes. Convert the five unstable_cache call sites to the use cache directive with cacheLife/cacheTag.
Math.random() during render is disallowed under Cache Components and broke static prerendering. Seed a deterministic width and randomize in useEffect, which also fixes the latent SSR/client hydration mismatch.
The toolbar calls Math.random() during render, which broke the Cache Components static prerender for ~88 routes (surfacing under MetadataOutlet). Gate it behind a mount effect so it never participates in server prerender.
generateMetadata via next-intl's getTranslations always reads the locale cookie through the request config, forcing every route's <head> to render dynamically and blocking the prerender. Resolve metadata in the default locale from the statically-imported catalog instead. Apply to the root layout and sign-in as the reference pattern.
Convert every generateMetadata to the cookie-free getMetadataTranslations helper so route <head> rendering no longer reads the locale cookie and can prerender. Dynamic routes keep their param/auth/DB lookups; only the localized strings move to the default-locale catalog. Also defer the TSR leaderboard's relative-time labels to after mount.
The OTel SDK generates span/trace IDs with Math.random(), which Cache Components forbids during prerendering — every prerendered route that ran a traced Prisma/Effect query failed. Telemetry has no value at build time, so skip tracer registration in phase-production-build; runtime requests still trace and export normally.
The recent-trends aggregation uses a Date.now() time window; wrap it in use cache (cacheLife hours) so it prerenders and the timestamp resolves once at cache fill.
Its data service derives a Date.now() window and localizes map names via the request locale, so it cannot prerender or cache cleanly. Read connection() first to mark it request-time under Cache Components (matching its prior always-dynamic behavior).
Make the converted generateMetadata functions synchronous (no await), thread the leaderboard relative-time `now` value through PlayerRow, and type the metadata translator against the translator's default options so typecheck and oxlint pass.
Required by the Vercel next-dev-loop and Cache Components skills (16.3+ for the /_next/mcp dev endpoint, export const instant, and instant-nav validation). Build stays green: 0 prerender failures, 92 Partial Prerender routes.
Switch to next@preview (16.3) and turn on partialPrefetching for instant navigations. Replace the map hero trends page's full-page connection() block with a Suspense boundary + content-shaped fallback so the route shows an instant shell on navigation while the trends stream in.
A map's stats never change once uploaded, so wrap getMostPlayedHeroes and
getMatchStory in use cache with cacheLife('max') tagged map:${mapId}, used by
the map and player pages. Invalidate that tag from the remove-map and
set-winner routes so the cache clears only when that specific map changes.
"use cache" functions must be async; the plain-function form threw at runtime (build-only checks missed it). Verified the map and player pages render with the cache layer.
Add scrim-level (scrim:${scrimId}) and team-stats (team-stats:${teamId}) cache
readers alongside the per-map ones, and centralize the tags in lib/cache-tags.
Wire revalidate calls into every scrim mutation path (create/add-map/remove-map/
remove-scrim/set-winner/update-scrim-options, plus the streaming variants) so a
scrim aggregate or a team's stats refresh exactly when a map or scrim changes.
SimpleSpanProcessor exports each span synchronously on end, reading Date.now() inside the render — which Cache Components rejects during the per-request shell prerenders the dev server (and partialPrefetching) run. BatchSpanProcessor defers export to a timer, off the render path, in every environment.
Render the static chrome immediately and move the authorization check (and the Footer, which reads the locale cookie + current year) into a Suspense boundary, so navigating to a scrim or map paints instantly instead of blocking. Clears the 16.3 Instant Insight for those routes and re-enables partialPrefetching.
Make Footer self-contained (its own Suspense) so it's safe in any static shell, stream DashboardLayout's auth-derived header and page content, and move the settings/team/team-stats layouts' request reads into Suspense children. Clears Instant Insights for every route built on these layouts.
Give every auth/flag-reading page segment its own loading.tsx (a shared RouteLoading skeleton) so the route paints an instant shell on navigation while its content streams — the per-segment boundary the shared layout's Suspense doesn't provide. Clears the remaining 16.3 Instant Insights.
The ThemeProvider was inside RootProviders, which streams under Suspense
behind auth/locale/flag reads, so next-themes' anti-flash script landed after
the document shell painted — the page flashed light before switching to the
stored theme. Render ThemeProvider in the static shell so its script runs
before first paint. defaultTheme is now request-independent ("system");
disguised stays selectable for DSG members but no longer auto-defaults.
The root Suspense used fallback={null}, so a hard reload painted a blank
document until RootProviders resolved its auth/locale/flag reads. Replace it
with a header-and-content skeleton that mirrors the app chrome, so a reload
shows the app loading and hands off cleanly to each route's loading.tsx.
…sight Expand route-loading into a small library of shape-matched shells — stats tabs (SectionHeader + StatRibbon + panel), list/feed, detail, and settings/form — and point each route family's loading.tsx at the matching one instead of the generic card grid. Also move the dashboard onboarding tour into a client component so useTranslations resolves from context rather than reading the request locale in the server layout, which was blocking the route.
Replace the five shared skeleton variants with a per-route loading.tsx that mirrors each page's actual structure — stat ribbons, comparison grids, match tables, radar charts, forms, message threads, etc. — so navigating to a route paints an accurate placeholder with no layout shift. Generated per route from the page + its layout chrome, then retire the now-unused route-loading library.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
2 Skipped Deployments
|
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
|
Warning Review the following alerts detected in dependencies. According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.
|
Migrates
apps/webto Next.js 16.3 Cache Components (PPR) and makes navigations instant, with route-accurate loading states throughout.What's in here (27 commits)
Cache Components / Next 16.3
cacheComponents(replacesexperimental.ppr); upgrade to Next 16.3-preview and turn onpartialPrefetching.unstable_cache→use cache.BatchSpanProcessorso trace export never readsDate.now()during a prerender.Tag-based caching
cacheTag+revalidateTaginvalidation on the relevant mutations.Instant navigation
DashboardLayout,Footer, and the scrim/map/settings/team/team-stats layouts so the chrome paints instantly and auth/data stream behind Suspense.loading.tsx, clearing the 16.3 Instant Insights app-wide.useTranslationsno longer reads the request locale in the server layout.Loading UX
nullSuspense fallback with an app-chrome boot skeleton (no more blank page on hard reload).Verification
pnpm typecheck,pnpm lint(oxlint),pnpm format:check(oxfmt), andpnpm build(0 prerender failures, 92 PPR routes) all pass. Authed routes spot-checked via headless browser; Instant Insights confirmed cleared across route families.Notes
defaultThemeis now request-independent (system); DSG members keep disguised as a selectable theme but it no longer auto-defaults on a first visit.next@16.3.0-preview.5.