Skip to content

Upgrade to Next.js 16.3 + migrate to App Router + adopt Cache Components#8492

Draft
aurorascharff wants to merge 10 commits into
reactjs:mainfrom
aurorascharff:upgrade-next-16
Draft

Upgrade to Next.js 16.3 + migrate to App Router + adopt Cache Components#8492
aurorascharff wants to merge 10 commits into
reactjs:mainfrom
aurorascharff:upgrade-next-16

Conversation

@aurorascharff

@aurorascharff aurorascharff commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

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:

  • The build is pinned to webpack because the custom webpack config and Sandpack's raw-loader imports aren't Turbopack-ready yet (explained in next.config.js).
  • The migration kept the existing Page component that every route renders and switches on section. Moving the shell into layouts and rendering MDX per-section would give automatic code-splitting and let MDX deserialize on the server. This pairs with making MDXComponents a dynamic barrel (see Refactor some imports to use dynamic loading resulting in about 30% decrease in first load js #8373).
  • eslint-config-next is pinned to 14 for ESLint 7 compatibility; a full ESLint upgrade would let it track Next 16.

cc @icyJoseph

@aurorascharff aurorascharff marked this pull request as ready for review June 22, 2026 22:49
Copilot AI review requested due to automatic review settings June 22, 2026 22:49
@aurorascharff aurorascharff marked this pull request as draft June 22, 2026 22:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 under src/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.

Comment thread src/app/layout.tsx
Comment thread src/app/layout.tsx
Comment thread src/lib/readMarkdownPage.ts Outdated
Comment thread package.json Outdated
Comment thread src/components/Layout/Page.tsx
aurorascharff added a commit to aurorascharff/react.dev that referenced this pull request Jun 22, 2026
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).
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.
@MaxwellCohen

Copy link
Copy Markdown

@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
image

Production JS coverage report from Chrome
image

Webpack build analysis shows a slight increase in client size bundles (920.36 KB gzip vs 880.62 KB gzip)

This PR
image

Main
image

On your maybe items

'use client' boundary on MDXComponents.tsx — there might be a cleaner split.

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.

Per-section catch-alls vs one big catch-all — current shape avoids runtime section sniffing but means N near-identical page.tsx files.

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.
@aurorascharff aurorascharff marked this pull request as ready for review June 23, 2026 10:45
@aurorascharff

aurorascharff commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator Author

@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 image

Production JS coverage report from Chrome image

Webpack build analysis shows a slight increase in client size bundles (920.36 KB gzip vs 880.62 KB gzip)

This PR image

Main image

On your maybe items

'use client' boundary on MDXComponents.tsx — there might be a cleaner split.

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.

Per-section catch-alls vs one big catch-all — current shape avoids runtime section sniffing but means N near-identical page.tsx files.

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.

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?

@MaxwellCohen

Copy link
Copy Markdown

Thank you for the performance updates. The lazy loading helps get JS downloaded to the same level as prod, great work!

image

@MaxwellCohen

Copy link
Copy Markdown

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.

@aurorascharff

Copy link
Copy Markdown
Collaborator Author

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!

@aurorascharff aurorascharff requested review from gaearon and removed request for gaearon June 23, 2026 13:34
@aurorascharff aurorascharff marked this pull request as draft June 23, 2026 13:36
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).
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.

3 participants