Skip to content

Upgrade to Next.js 16.2.4 (LTS)#1059

Draft
rchlfryn wants to merge 9 commits intomainfrom
chore/upgrade-next-16
Draft

Upgrade to Next.js 16.2.4 (LTS)#1059
rchlfryn wants to merge 9 commits intomainfrom
chore/upgrade-next-16

Conversation

@rchlfryn
Copy link
Copy Markdown
Collaborator

@rchlfryn rchlfryn commented May 5, 2026

Description

Upgrades the framework from Next.js 15.4.11 to Next.js 16.2.4 (LTS). Driven by @next/codemod@canary upgrade 16.2.4 plus manual fixes for breaking changes the codemod doesn't cover, then five follow-up commits to clear all the warnings/deprecations the upgrade surfaced.

Per the rationale in #1021, Next 16 was carved out as a dedicated PR so it could be reviewed and reverted independently.

Related Issues

N/A

Key Changes

Codemod-driven (automated by @next/codemod)

  • next 15.4.11 → 16.2.4
  • react / react-dom pinned to 19.2.5
  • eslint-config-next + @next/eslint-plugin-next 15.x → 16.2.4
  • src/middleware.ts renamed to src/proxy.ts and middleware()proxy() (Next 16 rename)
  • tsconfig.json: jsx: "preserve""react-jsx", .next/dev/types/**/*.ts added to include
  • pnpm overrides added for @types/react / @types/react-dom

Manual fixes for Next 16 breaking changes

  • @sentry/nextjs 9.x → 10.x. The 9.x peer dep excludes next@16; bumped to 10.51.0.
  • eslint-plugin-react-hooks 5.x → 7.x. eslint-config-next@16 ships the 7.x version transitively, so we bumped our direct dep to match.
  • revalidateTag(tag)revalidateTag(tag, 'default') in 14 callsites. Next 16 made the second profile arg required.
  • CSS @import moved above @tailwind directives in src/app/(frontend)/globals.css and src/app/(embeds)/a3-globals.css. Turbopack strictly enforces the CSS spec rule.
  • eslint.config.mjs rewritten to import eslint-config-next as native flat config (the FlatCompat shim no longer works because the package now exports Linter.Config[] directly).
  • jest.config.mjs: .claude/worktrees/ added to testPathIgnorePatterns so stale Claude worktree copies don't pollute jest runs.

Config deprecation cleanups

  • images.qualities: [75, 80] added to next.config.js. Next 16 requires an explicit allowlist for any non-default <Image quality={...}> value.
  • Sentry 10 webpack-keyed options. disableLogger and automaticVercelMonitors moved under webpack: { treeshake: { removeDebugLogging }, automaticVercelMonitors }.
  • Removed the unused webpack ignoreWarnings block in next.config.js. Sentry 10 fixed the upstream OpenTelemetry pattern that produced the warning, so the workaround is dead code.

React 19 / react-hooks 7.x cleanup (8 files)

The upgrade pulled in eslint-plugin-react-hooks 7.x with three new rules (set-state-in-effect, refs, static-components) that flagged 15 patterns at first. All resolved:

  • VersionDisplay.client.tsxuseEffect+setState hydration guard → useSyncExternalStore reading window.next.version.
  • ColumnLayoutPicker/index.tsx — dropped redundant local state mirroring Payload's useField value; derive layoutSelection during render.
  • InviteUserDrawer.tsx — hoisted RoleAssignmentsArray to module scope (was being recreated each render — static-components rule).
  • EventTable/Component.tsx — initialize loading from the eventOptions prop so the early-return setState in the effect goes away.
  • GenericEmbed/Component.tsx — extracted BlobIframe sub-component owning the blob URL via useMemo + cleanup effect; outer component uses useSyncExternalStore for SSR-safe DOMPurify output.
  • DateRangeFilter.tsx — derive quickFilter from URL state with a separate customMode flag for the "user clicked Custom while dates matched a quick filter" case. Removes the initial-mount sync effect.
  • TenantSelectionProvider/index.client.tsx — moved userChanged computation inside the auth-sync effect (was reading a ref during render); adopted the upstream @payloadcms/plugin-multi-tenant functional-setter pattern (setTenantOptions((prev) => prev.length > 0 ? [] : prev)) to drop tenantOptions from deps. Auth-state-transition setState calls are kept under a single targeted eslint-disable block — they're the canonical "subscribe to an external system, setState in response" pattern the rule explicitly accepts; matches upstream's design.
  • carousel.tsx — kept shadcn-pristine. ESLint config has a folder-scoped override for src/components/ui/** that disables the three new react-hooks rules so future pnpm dlx shadcn@latest add <name> re-pulls don't trip CI.

How to test

  • pnpm tsc — clean
  • pnpm lint — 0 errors, 0 warnings
  • pnpm test — 40/40 suites, 376/376 tests
  • pnpm build — production build succeeds (route summary shows Proxy (Middleware))
  • pnpm dev — Turbopack boots in ~340ms, no Sentry deprecation warnings, no unconfigured qualities warning, GET / returns 200
  • Verify Payload admin loads and CRUD works
  • Spot-check tenant frontends (NWAC, DVAC, SAC, SNFAC) render and images load
  • Verify revalidation hooks fire correctly on content edits (Pages, Posts, Navigations, Redirects, Tenants, HomePages, Widgets)
  • Verify proxy.ts middleware (was middleware.ts) still rewrites tenant subdomains correctly
  • Confirm Sentry events still flow on prod build

Screenshots / Demo video

N/A — framework upgrade only. CI build summary now shows Proxy (Middleware) instead of the old Middleware label.

Migration Explanation

No database migrations needed.

🤖 Generated with Claude Code

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 5, 2026

Preview deployment: https://chorexupgrade-next-16.preview.avy-fx.org

Major framework upgrade from 15.4.11. Run via @next/codemod@canary
upgrade and additional manual fixes for breaking changes.

Codemod changes (automated):
- next 15.4.11 → 16.2.4, react/react-dom pinned to 19.2.5
- eslint-config-next + @next/eslint-plugin-next 15 → 16.2.4
- src/middleware.ts → src/proxy.ts (Next 16 rename)
- tsconfig.json: jsx "preserve" → "react-jsx"; include .next/dev/types

Manual fixes:
- @sentry/nextjs 9.x → 10.x (peer dep needed for next 16)
- eslint-plugin-react-hooks 5.x → 7.x (matches next 16's transitive)
- revalidateTag(tag) → revalidateTag(tag, 'default') in 14 callsites:
  the second `profile` arg is required in Next 16
- src/app/(frontend)/globals.css and (embeds)/a3-globals.css: move
  @import above @tailwind directives — Turbopack strictly enforces
  the CSS spec rule that @import must precede other rules
- eslint.config.mjs: import eslint-config-next as native flat config
  (FlatCompat shim no longer works); downgrade three new react-hooks
  rules (set-state-in-effect, refs, static-components) to warn so
  pre-existing patterns don't block the upgrade — fix in follow-up
- jest.config.mjs: add .claude/worktrees/ to testPathIgnorePatterns
  so stale worktree copies don't pollute test runs

Verified: pnpm tsc, pnpm lint (warnings only), pnpm test (40 suites
/ 376 tests), pnpm build, dev server boots on Turbopack and serves
homepage with 200.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- images.qualities: [75, 80] — Next 16 requires explicit allowlist
  for any non-default Image quality. We use quality={80} on some
  components, which was emitting a console warning on every image
  request.
- Move Sentry's disableLogger and automaticVercelMonitors under the
  webpack: { ... } key. Sentry 10 deprecated the top-level options
  (logged a warning on dev startup), with no Turbopack equivalent
  yet. The webpack-keyed form is what Sentry 10 wants now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The webpack ignore was added to suppress a "Critical dependency"
warning from @opentelemetry/instrumentation that Sentry 9 surfaced
through its dynamic require pattern (sentry-javascript#12077).

Sentry 10 fixed the underlying pattern upstream. Verified by running
`pnpm build` with and without the block — both produce 0 warnings and
no OpenTelemetry mentions in the output.

Removing the block also unblocks one of the two follow-ups noted in
the Next 16 upgrade PR description: there's no longer a webpack-only
config we'd need a Turbopack equivalent for. If we ever opt builds
into Turbopack via `--turbopack`, there's nothing to port over.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
eslint-plugin-react-hooks 7.x ships with eslint-config-next@16 and
flags patterns that don't sit well with React Compiler. Promotes the
three downgraded rules (set-state-in-effect, refs, static-components)
back to default level by either fixing the patterns or accepting
narrowly-scoped suppressions in legitimate external-system-sync code.

Real fixes:
- VersionDisplay.client.tsx: replace useEffect+setState hydration
  guard with useSyncExternalStore reading window.next.version.
- ColumnLayoutPicker/index.tsx: drop the redundant local state that
  mirrored Payload's useField value; derive layoutSelection during
  render instead.
- InviteUserDrawer.tsx: hoist RoleAssignmentsArray to module scope
  (it was being recreated each render — static-components rule).
- EventTable/Component.tsx: initialize `loading` from `eventOptions`
  prop so the early-return setState in the effect goes away.
- carousel.tsx: replace the onSelect+useEffect+setState dance with
  useSyncExternalStore subscribed to embla's reInit/select events.
- GenericEmbed/Component.tsx: extract BlobIframe sub-component owning
  the blob URL via useMemo + cleanup effect. Outer component uses
  useSyncExternalStore for SSR-safe DOMPurify output. No state sync.
- DateRangeFilter.tsx: derive quickFilter from URL state with a
  separate `customMode` flag for the "user clicked Custom while dates
  matched a quick filter" case. Removes the initial-mount sync effect.

Targeted suppressions:
- TenantSelectionProvider/index.client.tsx: the auth-sync effects
  (login/logout transitions, stale-cookie cleanup, global-entity
  auto-select) are the canonical "subscribe to an external system,
  setState in response" pattern the rule explicitly accepts. Kept a
  single eslint-disable block over the auth-sync effects with a
  comment explaining the design.

eslint.config.mjs: removed the temporary `'warn'` overrides for the
three rules now that the codebase is clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@payloadcms/plugin-multi-tenant's TenantSelectionProvider uses
`setTenantOptions((prev) => prev.length > 0 ? [] : prev)` in the
auth-sync effect, which avoids needing `tenantOptions` in the deps
array. Smaller deps = fewer effect runs (the effect now only runs on
userID/initialValue/syncTenants/router changes, not whenever tenant
options shift).

Mirrors:
https://github.com/payloadcms/payload/blob/main/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.client.tsx

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The carousel refactor in 534484e diverged src/components/ui/carousel.tsx
from upstream shadcn output, which would force us to redo the
useSyncExternalStore migration every time we re-pull (e.g. via
`pnpm dlx shadcn@latest add carousel`). Restored the pristine shadcn
version and added a targeted ESLint override that disables the three
new react-hooks rules (set-state-in-effect, refs, static-components)
for src/components/ui/** so future shadcn re-pulls don't trip CI.

Other src/** files remain at default rule level.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Next 16 added an SSRF guard that blocks the image optimizer from
fetching source URLs that resolve to loopback or private IPs. In dev,
`getMediaURL` produces `http://localhost:3000/api/media/file/...` which
resolves to 127.0.0.1, so every <Image> on a page logs:

  ⨯ upstream image http://localhost:3000/api/... resolved to private ip

Setting `images.localPatterns = [{ pathname: '/api/**' }]` is the
official Next 16 escape hatch — it tells the optimizer that fetching
same-origin /api/** paths is intentional and should skip the SSRF
check. Same behavior in production (the path matches there too).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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