Skip to content

Migrate apps/web to Next.js 16 Cache Components with instant navigation - #80

Merged
lucasdoell merged 27 commits into
stagingfrom
feat/cache-components-migration
Jun 26, 2026
Merged

Migrate apps/web to Next.js 16 Cache Components with instant navigation#80
lucasdoell merged 27 commits into
stagingfrom
feat/cache-components-migration

Conversation

@lucasdoell

Copy link
Copy Markdown
Member

Migrates apps/web to 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

  • Enable cacheComponents (replaces experimental.ppr); upgrade to Next 16.3-preview and turn on partialPrefetching.
  • Remove route-segment configs and migrate unstable_cacheuse cache.
  • Stream the root provider tree under Suspense; resolve route metadata in the default locale (cookie-free) so heads prerender.
  • Gate OpenTelemetry off the build phase and switch to BatchSpanProcessor so trace export never reads Date.now() during a prerender.

Tag-based caching

  • Cache immutable per-map reads aggressively and scrim/team-stats aggregates with cacheTag + revalidateTag invalidation on the relevant mutations.

Instant navigation

  • Stream auth-gated content in the shared DashboardLayout, Footer, and the scrim/map/settings/team/team-stats layouts so the chrome paints instantly and auth/data stream behind Suspense.
  • Give every auth/i18n-reading route segment its own loading.tsx, clearing the 16.3 Instant Insights app-wide.
  • Move the dashboard onboarding tour to a client component so useTranslations no longer reads the request locale in the server layout.

Loading UX

  • Hoist next-themes into the static document shell to stop the light-mode flash before first paint.
  • Replace the root null Suspense fallback with an app-chrome boot skeleton (no more blank page on hard reload).
  • Bespoke per-route loading skeleton for all 72 segments, each mirroring its page's real structure (stat ribbons, comparison grids, match tables, radar charts, forms, message threads, …).

Verification

pnpm typecheck, pnpm lint (oxlint), pnpm format:check (oxfmt), and pnpm build (0 prerender failures, 92 PPR routes) all pass. Authed routes spot-checked via headless browser; Instant Insights confirmed cleared across route families.

Notes

  • defaultTheme is now request-independent (system); DSG members keep disguised as a selectable theme but it no longer auto-defaults on a first visit.
  • Running on next@16.3.0-preview.5.

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.
@vercel

vercel Bot commented Jun 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
parsertime Ready Ready Preview, Comment Jun 26, 2026 5:05pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jun 26, 2026 5:05pm
docs-legacy Skipped Skipped Jun 26, 2026 5:05pm

@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatednpm/​next@​16.2.9 ⏵ 16.3.0-preview.543 -20100909970

View full report

@socket-security

Copy link
Copy Markdown

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.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Obfuscated code: npm next is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: apps/web/package.jsonnpm/next@16.3.0-preview.5

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/next@16.3.0-preview.5. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: npm next is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: apps/web/package.jsonnpm/next@16.3.0-preview.5

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/next@16.3.0-preview.5. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: npm next is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: apps/web/package.jsonnpm/next@16.3.0-preview.5

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/next@16.3.0-preview.5. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: npm next is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: apps/web/package.jsonnpm/next@16.3.0-preview.5

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/next@16.3.0-preview.5. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

@lucasdoell
lucasdoell merged commit 7b2c294 into staging Jun 26, 2026
15 checks passed
@lucasdoell
lucasdoell deleted the feat/cache-components-migration branch June 26, 2026 17:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant