Upgrade to Next.js 16.3 + migrate to App Router + adopt Cache Components#8492
Upgrade to Next.js 16.3 + migrate to App Router + adopt Cache Components#8492aurorascharff wants to merge 10 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR upgrades the site to Next.js 16.3 canary + React 19.2 and migrates routing from the Pages Router to the App Router, enabling Cache Components and moving SEO/head logic to the Metadata API.
Changes:
- Upgrade runtime/tooling (Next 16.3 canary, React 19.2, Node >=20.9, TS/ESLint adjustments) and update TS config/types.
- Migrate content rendering to App Router routes under
src/app/with server-only helpers undersrc/lib/. - Replace the legacy
<Seo>/Pages Router head setup with Metadata + root layout (src/app/layout.tsx) and add new client-side effects shims (analytics, scroll restoration).
Reviewed changes
Copilot reviewed 44 out of 51 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| tsconfig.json | Switch TS module resolution/JSX mode and include additional Next-generated type globs. |
| src/utils/compileMDX.ts | Stop importing client-only MDX components by enumerating component names via a server-safe list; update return type to include languages. |
| src/types/jsx-bridge.d.ts | Add React 19 JSX namespace bridge typings. |
| src/types/css.d.ts | Add CSS module type shims (including @docsearch/css). |
| src/pages/errors/index.tsx | Remove Pages Router error decoder index route. |
| src/pages/errors/[errorCode].tsx | Remove Pages Router error decoder dynamic route + SSG logic. |
| src/pages/[[...markdownPath]].js | Remove Pages Router catch-all MDX route + SSG path collection. |
| src/pages/_document.tsx | Remove Pages Router custom document (head/scripts move to App Router layout). |
| src/pages/_app.tsx | Remove Pages Router app wrapper (global CSS + effects move to App Router layout/client effects). |
| src/lib/readMarkdownPage.ts | New server-only helper to read/compile MDX pages from src/content. |
| src/lib/loadErrorDecoderData.ts | New server-only helper to load error code data + compile MDX for error decoder routes. |
| src/lib/collectPaths.ts | New server-only helpers to enumerate content routes for static params generation. |
| src/lib/buildPageMetadata.ts | New Metadata builder to replace legacy <Seo> behavior for canonical/alternates/OG/Twitter. |
| src/hooks/usePendingRoute.ts | Remove route-transition pending-state tracking (no App Router router.events). |
| src/components/Search.tsx | Migrate navigation to next/navigation and make Search a client component. |
| src/components/PageHeading.tsx | Migrate router usage to usePathname. |
| src/components/MDX/MDXComponentsList.ts | New server-safe list of MDX component names for MDX compilation. |
| src/components/MDX/MDXComponents.tsx | Mark as client component; document syncing with MDXComponentsList. |
| src/components/MDX/ExpandableExample.tsx | Replace next/router hash parsing with window.location.hash on client. |
| src/components/MDX/Challenges/Challenges.tsx | Replace next/router usage with App Router primitives + hash handling. |
| src/components/Layout/useDeserializedMDX.tsx | New client hook to deserialize MDX JSON into React nodes. |
| src/components/Layout/TopNav/TopNav.tsx | Migrate routing to usePathname. |
| src/components/Layout/Sidebar/SidebarRouteTree.tsx | Migrate routing to usePathname. |
| src/components/Layout/Page.tsx | Convert to client component; remove <Seo>/next/head; accept pathname from server; render content with new MDX deserialization flow. |
| src/components/Layout/HomeContent.js | Switch from custom use polyfill to React’s built-in use. |
| src/components/ErrorDecoderContext.tsx | Update comments to reflect App Router/server component usage. |
| src/components/Seo.tsx | Remove legacy SEO component in favor of Metadata API. |
| src/app/warnings/[slug]/page.tsx | New App Router route for warnings pages (static params + metadata). |
| src/app/versions/page.tsx | New App Router route for versions page (cache + metadata). |
| src/app/renderSectionPage.tsx | New shared server renderer + metadata helper for sectioned MDX pages. |
| src/app/reference/[[...slug]]/page.tsx | New App Router catch-all route for reference section. |
| src/app/page.tsx | New App Router home route using server MDX read + metadata builder. |
| src/app/not-found.tsx | New App Router not-found page using <Page> with explicit pathname/section. |
| src/app/llms.txt/route.ts | Migrate llms.txt generator to App Router route handler (GET). |
| src/app/learn/[[...slug]]/page.tsx | New App Router catch-all route for learn section. |
| src/app/layout.tsx | New root layout: global CSS, viewport/metadata base, scripts, preloads, and client effects. |
| src/app/errors/page.tsx | New App Router index route for error decoder. |
| src/app/errors/ErrorDecoderView.tsx | New client view to deserialize/render error decoder MDX and provide context. |
| src/app/errors/[errorCode]/page.tsx | New App Router dynamic error decoder route + static params + metadata. |
| src/app/error.tsx | New global error boundary page for App Router. |
| src/app/DocsPage.tsx | New client wrapper to feed deserialized MDX + toc into <Page>. |
| src/app/community/[[...slug]]/page.tsx | New App Router catch-all route for community section. |
| src/app/clientEffects.tsx | New client-only analytics + scroll restoration effects (replacing _app.tsx). |
| src/app/blog/[[...slug]]/page.tsx | New App Router catch-all route for blog section. |
| src/app/api/md/[...path]/route.ts | Migrate legacy API route to App Router route handler (GET). |
| package.json | Update scripts for --webpack, adjust lint scripts, bump Next/React/types, and raise Node engine. |
| next.config.js | Enable Cache Components + React compiler settings; add serverExternalPackages; keep webpack-based builds. |
| next-env.d.ts | Update Next TypeScript env declarations (now includes .next type imports). |
| CLAUDE.md | Update repo structure notes and add Next agent rules block. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
From Copilot's automated review on PR reactjs#8492: - layout.tsx: render `<meta property="fb:app_id">` as a property tag directly in <head> (Next's `metadata.other` only emits name=). - layout.tsx: restore the RSS autodiscovery <link> and the Algolia preconnect <link> that lived in the old Pages Router <Head>. - readMarkdownPage.ts: switch readFileSync -> fs.promises.readFile to avoid blocking the event loop while compiling MDX. - package.json: bump @types/node from ^14 to ^20 to match the new engines.node >=20.9.0. - buildPageMetadata.ts + renderSectionPage.tsx + learn/blog generateMetadata: thread the section's routeTree through so we can re-emit the `algolia-search-order` meta tag on Learn pages and Blog posts (matches the old <Seo> behavior; Algolia uses it for ordering).
Runs `npx @next/codemod@canary upgrade canary`: - next 15.1.12 → 16.3.0-canary.60 - react / react-dom ^19.0.0 → 19.2.7 (pinned) - @types/react / @types/react-dom 19.2.x (pinned via resolutions) - eslint-config-next 12.0.3 → 14 (last version supporting ESLint 7; 16.x requires ESLint 8+ which is a separate upgrade) - engines.node >=16.8.0 → >=20.9.0 (Next 16 minimum) Type shims for React 19's stricter @types/react: - src/types/jsx-bridge.d.ts re-exports React.JSX as the global JSX namespace used by existing Icon components - src/types/css.d.ts provides ambient declarations for .css side-effect imports (needed under moduleResolution: 'bundler') - @types/prop-types added (transitively required by legacy forwardRefWithAs.tsx) Auto-updates from the codemod: - tsconfig.json: moduleResolution 'node' → 'bundler', jsx 'preserve' → 'react-jsx', .next/dev/types/** added to include - next-env.d.ts: routes.d.ts and root-params.d.ts references - CLAUDE.md: nextjs-agent-rules block injected by next dev (intended to be committed per the block's own instructions)
Migrates the docs site from the Pages Router to the App Router and
enables Cache Components (`experimental.cacheComponents` now top-level
`cacheComponents: true`).
Route tree (`src/app/`):
- `layout.tsx` — root layout (theme/uwu init script, fonts, GA, scroll
restoration); replaces _document.tsx + _app.tsx
- `page.tsx` — home (`section: 'home'`)
- `{learn,reference,community,blog}/[[...slug]]/page.tsx` — section-
specific catch-all docs pages (each owns its sidebar tree, section
literal, and metadata generator)
- `warnings/[slug]/page.tsx`, `versions/page.tsx` — flat sections
- `errors/page.tsx`, `errors/[errorCode]/page.tsx` — error decoder
- `not-found.tsx`, `error.tsx` — error/404 boundaries
- `api/md/[...path]/route.ts`, `llms.txt/route.ts` — Route Handlers
replacing the old API/static endpoints
- `renderSectionPage.tsx`, `DocsPage.tsx`, `clientEffects.tsx` —
shared route helpers (server + client respectively)
Shared helpers (`src/lib/`, server-only):
- `readMarkdownPage.ts` — MDX read + compile from src/content
- `collectPaths.ts` — generateStaticParams enumeration
- `buildPageMetadata.ts` — canonical URL + hreflang alternates +
open graph (replaces the old <Seo> component)
- `loadErrorDecoderData.ts` — error-codes fetch + MDX compile
Component-level changes:
- `next/router` → `next/navigation` across Page.tsx, Search.tsx,
PageHeading.tsx, TopNav.tsx, SidebarRouteTree.tsx,
ExpandableExample.tsx, Challenges.tsx
- `Page.tsx` takes `section` and `pathname` as props instead of
sniffing them at runtime via `usePathname`; `<Seo>` and `<Head>`
removed (replaced by App Router Metadata API)
- `MDXComponents.tsx` is now `'use client'` so the registry can host
client-only components (Sandpack etc.); a parallel server-safe
`MDXComponentsList.ts` keeps the component name list reachable from
`compileMDX.ts`
- `useDeserializedMDX.tsx` — shared hook for revival of the
serialized React tree on the client
- `usePendingRoute.ts` is a no-op (App Router has no equivalent of
`router.events.routeChangeStart`; <Link> handles transitions)
next.config.js:
- `cacheComponents: true` and `reactCompiler: true` lifted out of
`experimental`
- `serverExternalPackages` lists the Babel + MDX deps that compileMDX
loads via runtime `require` (otherwise Next bundles them and breaks
inside the cache scope)
- `turbopack: {}` placeholder so the build picks the webpack pipeline
(the project's existing webpack config + Sandpack `raw-loader`
imports aren't Turbopack-compatible yet)
Cache Components adoption:
- `'use cache'` on every page default export and `generateMetadata`
(including `/errors/[errorCode]`)
- Zero `export const instant = false` opt-outs
- Build output: 816 routes, all Static or Partial Prerender, only
`/api/md/[...path]` is dynamic
Other:
- `use(promise)` polyfill in HomeContent.js dropped in favour of
React 19's built-in `use`
- `ErrorDecoderContext.tsx` doc comment refreshed to reflect that
context now flows from a server component, not getStaticProps
- `worker-bundle.dist.js` regenerated by scripts/buildRscWorker.mjs
with the updated React 19.2 runtime (much smaller)
From Copilot's automated review on PR reactjs#8492: - layout.tsx: render `<meta property="fb:app_id">` as a property tag directly in <head> (Next's `metadata.other` only emits name=). - layout.tsx: restore the RSS autodiscovery <link> and the Algolia preconnect <link> that lived in the old Pages Router <Head>. - readMarkdownPage.ts: switch readFileSync -> fs.promises.readFile to avoid blocking the event loop while compiling MDX. - package.json: bump @types/node from ^14 to ^20 to match the new engines.node >=20.9.0. - buildPageMetadata.ts + renderSectionPage.tsx + learn/blog generateMetadata: thread the section's routeTree through so we can re-emit the `algolia-search-order` meta tag on Learn pages and Blog posts (matches the old <Seo> behavior; Algolia uses it for ordering).
ede1b43 to
85879bd
Compare
The App Router migration and Cache Components (`cacheComponents`, `'use cache'`) are all supported in stable 16.2.9 — the canary pin was only needed during the migration itself. Builds clean with no config warnings; all 816 routes still Static / Partial Prerender. We removed every `instant = false` opt-out, so the 16.3-only `instant` route config isn't used and there's nothing tying us to canary.
Per review feedback from @icyJoseph: instead of 'use cache' on every page and generateMetadata, cache at the utility/data layer so callers just work and the page render + generateMetadata share one compile. - readMarkdownPage, collectSectionPaths, collectFlatSectionSlugs, loadErrorCodes, and a new compileErrorDecoderData helper are now 'use cache' + cacheLife('max') (stable content, only changes on deploy; revalidate 30d instead of the default 15m). - loadErrorDecoderData stays uncached and keeps the notFound() check, which can't run inside a 'use cache' scope; it delegates the cached compile to compileErrorDecoderData. - Removed 'use cache' from all 9 routes and their generateMetadata. Build unchanged: 816 routes, all Static / Partial Prerender.
cac6d3d to
98417e2
Compare
|
@aurorascharff, I am so glad to see you talking up the challenge of moving to app router. I really hope that we can get this upgrade over the finish line. I did some quick performance testing on the useActionState page (this pr build on my machine vs react.dev), and INP and LCP are the same, which is good. When I tested the previous attempt to use app router #8338 there was a slight decrease. I am, however, still seeing an increase in JS sent to the browser (2.2 MB vs 2.0 MB) and used (1.1 MB vs 971 KB). localhost JS coverage report from Chrome Production JS coverage report from Chrome Webpack build analysis shows a slight increase in client size bundles (920.36 KB gzip vs 880.62 KB gzip) On your maybe items
IMO MDXComponents.tsx should essentially be a barrel file for React.lazy, Next.js's dynamic components, or server components. This can greatly reduce the JavaScript bundle size. I created a proof-of-concept PR #8373 that can serve as a reference.
The src/components/Layout/Page.tsx is really a mix-and-match of the diffrent layouts, so splitting that into the diffrent layouts.tsx and having the app router's page.tsx calls MDX render as a server component might be a good approach. I personally do not like catch-all routes because they result in JS-heavy pages and extra logic that I think should be handled by Next.js. Resulting in problems like currently, every page has all the home page info in the JS bundle Overall, these changes make the site feel faster to navigate. |
Per review feedback from @MaxwellCohen: <Page> is a shared client component rendered by every route, and it statically imported HomeContent (~2.7k LOC of homepage-only marketing/animation code). That pulled the homepage into the shared client bundle of every docs, reference and blog page. Load it with next/dynamic instead. The homepage chunk (~116 KB) now loads only at '/'; a representative docs route drops from ~941 KB to ~830 KB of client JS.
Per review feedback from @icyJoseph: the /api/md/[...path] handler that serves raw markdown was Dynamic (a function per request). The content set is fixed at build time, so prerender it instead. - Add generateStaticParams (new collectAllContentPaths in collectPaths) enumerating every .md under src/content. - 'use cache' + cacheLife('max') the file read (the dynamic/dynamicParams route segment configs are disallowed under cacheComponents). Result: /api/md/[...path] is now SSG; +221 prerendered markdown endpoints. Served from the CDN, so self-hosted clones stay static too.
Paths that match a section catch-all but have no backing .md file (e.g. /learn/state, a sidebar header) were 500ing. The fs read threw inside readMarkdownPage's 'use cache' scope, which surfaces as a render error instead of falling through to notFound(). readMarkdownPage now returns PageData | null for a missing file instead of throwing; callers decide notFound(). Removes the now-redundant safeReadPage try/catch wrapper.
Expand the comment above the empty `turbopack: {}` to explain why the
build runs on webpack (custom webpack config + Sandpack raw-loader
imports aren't Turbopack-ready) and that it's a tracked follow-up.
Thanks Maxwell! The +40 KB was from before I pushed a couple of follow-ups. I re-measured the useActionState page on both branches locally and it's basically at parity now, around 265 KB gzip on main vs 263 KB here. The fix was exactly what you spotted: the Page component statically imported HomeContent, so every page was shipping the homepage. It's dynamically imported now, so only the home route loads it. On the architecture ideas, both are in the follow-ups section and I agree with the direction. I prototyped the MDXComponents barrel like in your POC: it works, but only saved about 7 KB, since the heavy part (CodeMirror) is already lazy-loaded inside SandpackRoot. The Page-to-layouts split is the bigger win and would also let MDX deserialize on the server, but it's a larger change, so I'd rather land the migration first and do that as a dedicated follow-up. What do you think? |
Very reasonable plan, at the end of the day, I want to see react.dev to be as fast as possible and showcase React's greatness both in content and implementation. There will be many changes because React and Next.js have evolved significantly over the last 3 years. |
Yes, definitely! A lot of improvements to be made here! |
These files dropped the '/* Copyright (c) Facebook, Inc. */' banner when the 'use client' directive was added. Restore it above the directive (comments may precede a directive without disabling it).









Upgrades the site to Next.js 16, migrates from the Pages Router to the App Router, and turns on Cache Components.
Mostly authored by an agent (Copilot agent mode, Claude Opus 4.7) driven by the canary next-cache-components-adoption skill.
What changed
Next.js 15.1 to 16.2.9, React 19.0 to 19.2.7.
The Pages Router moves to the App Router under src/app, with server-only data helpers in src/lib and per-section routes for learn, reference, community, blog, warnings, versions, and errors.
Cache Components is enabled. Caching lives at the data layer (readMarkdownPage, collectPaths, the error-decoder loaders) with cacheLife('max'), so the pages themselves stay plain and the page render shares one compile with generateMetadata. There are no instant = false opt-outs, and every route is static or partially prerendered.
Along the way: the old Seo component is replaced by the Metadata API, next/router becomes next/navigation, the raw markdown route (/api/md) is prerendered as static files instead of a per-request function, and the homepage is lazy-loaded so only / ships it.
Translations are unchanged: same subdomain-per-language model, forks just set siteConfig.languageCode.
On the diff size
worker-bundle.dist.js shows ~32k deleted lines, but it is a generated artifact (scripts/buildRscWorker.mjs) that regenerated smaller under React 19.2. The hand-written change is about 49 files.
Deferred
These are intentionally left as follow-ups:
cc @icyJoseph