close
Skip to content

chore: upgrade to React 19, Next.js 16, Apollo Client 4, Apollo Server 5#2904

Merged
huumn merged 23 commits intostackernews:masterfrom
Soxasora:chore/update-nextjs-16
Apr 14, 2026
Merged

chore: upgrade to React 19, Next.js 16, Apollo Client 4, Apollo Server 5#2904
huumn merged 23 commits intostackernews:masterfrom
Soxasora:chore/update-nextjs-16

Conversation

@Soxasora
Copy link
Copy Markdown
Member

@Soxasora Soxasora commented Apr 5, 2026

Description

This PR aims at upgrading Next.js from 14 to 16.
Other than performance gains, the most recent reason to update is the introduction of the Node.js Proxy (ex-middleware), enabling custom domains (#2897) db lookup right inside the proxy, without needing an API endpoint to do so.

Upgrades

  • Next.js 14.2.25 -> 16.2.2
    • node.js middleware/proxy by default
  • React 18.3.1 -> 19.2.4
    • required by Next.js 16 and recommended by Lexical
  • GraphQL 16.9.0 -> 16.13.2
    • required by Apollo
  • Apollo Client 3.11.8 -> 4.1.6
    • React 19 support
  • Apollo Server 4.11.0 -> 5.5.0
    • React 19 support
  • Apollo Server Integrations for Nextjs 3.1.0 -> 4.1.0
    • Next.js 16 support
  • Plausible 4.0.0
    • React 19 support
  • @yudiel/react-qr-scanner 2.0.8 -> 2.5.1
    • React 19 support
  • qrcode.react 4.0.1 -> 4.2.0
    • React 19 support
  • react-avatar-editor 13.0.2 -> 15.1.0
    • React 19 support
  • recharts 2.13.0 -> 2.15.4
    • React 19 support
    • react-is 19.2.4 override
      • can be removed after upgrade to recharts 3.x
  • eslint-plugin-next 14.2.15 -> 16.2.2
  • eslint-config-next 16.2.2

Changes

TBD

Next.js rewrites
      {
        source: '/~:sub/:slug*\\?:query*',
        destination: '/~/:slug*?:query*&sub=:sub'
      },

This rewrite rule has been removed because it's not supported by Next.js 15+
but this rule already does the same thing and is supported:

      {
        source: '/~:sub/:slug*',
        destination: '/~/:slug*?sub=:sub'
      },

Screenshots

Stacker News running on Next.js 16.2.2

Screenshot 2026-04-05 at 17 39 30

Additional Context

The upgrade to Next.js 16 and React 19 required the upgrade of other core libraries, such as Apollo Client/Server.
Upgrading Apollo meant handling breaking changes to useLazyQuery, useQuery, and ApolloLink. Everything seems to be working as intended but still needs deep QA.


Safari DevTools are broken since Next.js 15 vercel/next.js#78524 if Next.js is ran without Turbopack.


Next.js now catches console.errors and show them as thrown errors in dev mode.


Apollo

Apollo breaking changes
  1. The notifyOnNetworkStatusChange option now defaults to true. This affects React hooks that provide this option (e.g. useQuery) and ObservableQuery instances created by client.watchQuery.
    This change means you might see loading states more often, especially when used with refetch or other APIs that cause fetches. If this causes issues, you can revert to the v3 behavior by setting the global default back to false.

  2. If we use useLazyQuery to execute queries but don't read the state values returned in the result tuple, it is now recommended to use client.query directly. src

    • In Apollo Client 4, useLazyQuery focuses on user interaction and more predictable data synchronization with the component. Network requests are now initiated only when the execute function is called. This makes it safe to re-render your component with new options provided to useLazyQuery without the unintended network requests. New options are used the next time the execute function is called.
    • Calling the execute function of useLazyQuery during render is no longer allowed and will now throw an error, recommend instead migrating to useQuery which executes the query during render automatically.
    • In-flight queries are now aborted more frequently. This occurs under two conditions:
      The component unmounts while the query is in flight
      A new query is started by calling the execute function while a previous query is in flight
      In each of these cases, the promise returned by the execute function is rejected with an AbortError. This change means it is no longer possible to run multiple queries simultaneously.
      In some cases, you may need the query to run to completion, even if a new query is started or your component unmounts. In these cases, you can call the .retain() function on the promise returned by the execute function.
  3. useQuery got rid of onCompleted and onError callbacks

    • hasNewNotes used onCompleted and has been migrated to useEffect pattern
  4. New requirements for custom cache implementations
    Custom cache implementations must now implement the fragmentMatches method, which is required for fragment matching in Apollo Client 4.

Checklist

Are your changes backward compatible? Please answer below:

tbd

On a scale of 1-10 how well and how have you QA'd this change and any features it might affect? Please answer below:

7, everything seems to be working. QA done on payments, wallets, normal website interaction (post, comment, navigation), live comments, editor

For frontend changes: Tested on mobile, light and dark mode? Please answer below:
n/a

Did you introduce any new environment variables? If so, call them out explicitly here:
Yes, with Next.js 15/16 localhost is the default and only allowed dev origin. This has been modified to ensure it also always includes NEXT_PUBLIC_URL and to support additional origins, we now have a new env var:

ALLOWED_DEV_ORIGINS=

By default it's empty, which means that only localhost and NEXT_PUBLIC_URL are allowed.

Did you use AI for this? If so, how much did it assist you?

  • help with Apollo migration
  • upgrade from Next.js 15 to 16 via Next.js Devtools MCP (Claude)

Note

High Risk
High risk because it upgrades core runtime dependencies (Next.js/React/Apollo) and rewires Apollo hook/query behavior (notably useLazyQuery promise/abort semantics and removal of onCompleted), which can affect navigation, data fetching, and payments-related flows across the app.

Overview
Upgrades the frontend stack to Next.js 16/React 19 and adapts the codebase for Apollo Client 4 by switching React hooks imports to @apollo/client/react, enabling @client directive support via LocalState, and updating client config (e.g., ApolloLink.from, devtools config, and default notifyOnNetworkStatusChange: false).

Refactors multiple useLazyQuery/useQuery call sites to the new promise-based/abort-heavy behavior: moves onCompleted logic into .then(...)/useEffect, adds centralized isAbortError filtering to avoid noisy errors on aborted requests, and updates a few components (popovers, upload fee refresh, suggestions, auto-retry pay-ins) to execute queries explicitly and handle errors.

Adjusts Next.js config for v15+ compatibility and dev ergonomics: adds allowedDevOrigins derived from NEXT_PUBLIC_URL plus new ALLOWED_DEV_ORIGINS, removes an unsupported rewrite, adds transpilePackages, and silences bootstrap sass deprecation warnings; also adds a new flat eslint.config.mjs.

Reviewed by Cursor Bugbot for commit 36ab54b. Bugbot is set up for automated code reviews on this repo. Configure here.

Soxasora and others added 2 commits April 5, 2026 16:28
…upgrade Apollo Client/Server, other libs that didn't support React 19

- Next.js 14.2.25 -> 15.5.14
- React 18.3.1 -> 19.2.4
- GraphQL 16.9.0 -> 16.13.2
- Apollo Client 3.11.8 -> 4.1.6
- Apollo Server 4.11.0 -> 5.5.0
- Apollo Server Integrations for Nextjs 3.1.0 -> 4.1.0
- \@yudiel/react-qr-scanner 2.0.8 -> 2.5.1
- qrcode.react 4.0.1 -> 4.2.0
- react-avatar-editor 13.0.2 -> 15.1.0
- recharts 2.13.0 -> 2.15.4
  - react-is 19.2.4 override
  - can be removed after upgrade to recharts 3.x
- eslint-plugin-next 14.2.15 -> 15.5.14
+ eslint-config-next 15.5.14
@Soxasora Soxasora changed the title chore: upgrade to React 19 and Next.js 16.x chore: upgrade to React 19, Next.js 16, Apollo Client 4, Apollo Server 5 Apr 5, 2026
@Soxasora Soxasora marked this pull request as ready for review April 5, 2026 15:52
Comment thread api/ssrApollo.js Outdated
Comment thread components/form.js
@Soxasora Soxasora marked this pull request as draft April 5, 2026 22:14
Soxasora added 2 commits April 6, 2026 01:27
… a server external

DefinePlugin can't otherwise replace the env var internally used by next-plausible to determine if we're using the proxy or not
…get the full error where errors are manually handled
@Soxasora Soxasora marked this pull request as ready for review April 5, 2026 23:52
Copy link
Copy Markdown
Contributor

@sir-opti sir-opti left a comment

Choose a reason for hiding this comment

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

Did some code review on the diff, pre-testing.

Comment thread pages/items/[id]/edit.js Outdated
Comment thread components/payIn/hooks/use-pay-in-mutation.js Outdated
Comment thread worker/index.js Outdated
Comment thread worker/index.js Outdated
@Soxasora
Copy link
Copy Markdown
Member Author

Soxasora commented Apr 6, 2026

Hi opti! All the things you found were added automatically by the Apollo Client 4 migration codemod. The @defer directive support was something I've already removed but probably got added again when I re-ran the codemod on worker and api.

Thanks you so much for checking this PR, I'll address immediately 🫡

if (data && !data.hasNewNotes) {
clearNotifications()
}
}, [data?.hasNewNotes])
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

useEffect fires less often than replaced onCompleted callback

Low Severity

The useEffect with [data?.hasNewNotes] dependency only fires when the value changes, whereas the old onCompleted callback fired on every successful poll response. This means clearNotifications() is no longer called repeatedly when hasNewNotes stays false across consecutive polls. If a push notification arrives but the server-side hasNewNotes value doesn't transition (remains false), the notification won't be cleared until the next value change. The old behavior acted as a continuous cleanup sweep.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7a6367c. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This seems like a good thing.

Comment thread components/form.js Outdated
@sir-opti
Copy link
Copy Markdown
Contributor

sir-opti commented Apr 7, 2026

Seeing this, a lot:

app  | 2026-04-07T21:23:28.213872810Z `legacyBehavior` is deprecated and will be removed in a future release. A codemod is available to upgrade your components:
app  | 2026-04-07T21:23:28.213915174Z 
app  | 2026-04-07T21:23:28.213925669Z npx @next/codemod@latest new-link .
app  | 2026-04-07T21:23:28.213957697Z 
app  | 2026-04-07T21:23:28.213963226Z Learn more: https://nextjs.org/docs/app/building-your-application/upgrading/codemods#remove-a-tags-from-link-components

Comment thread components/user-header.js
Comment on lines -52 to +53
<Link href={'/' + user.name} passHref legacyBehavior>
<Nav.Link eventKey='bio'>bio</Nav.Link>
</Link>
<Nav.Link as={Link} href={'/' + user.name} eventKey='bio'>bio</Nav.Link>
Copy link
Copy Markdown
Member Author

@Soxasora Soxasora Apr 8, 2026

Choose a reason for hiding this comment

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

This change has been applied to every instance of NextLink used with legacyBehavior. We're giving NextLink as the component to be used in place of the default <a> tag.

passHref then becomes useless as Nav.Link, for example, gives the href to whatever as component we indicate.


@sir-opti I was ignoring that deprecation warning to avoid touching too many things, but the solution is quite straightforward.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is an awesome change because now my eyes only hurt 200% instead of 500% from looking at that indentation 🤣

@sir-opti
Copy link
Copy Markdown
Contributor

sir-opti commented Apr 8, 2026

Haven't found anything new between posting/deleting, editor funk, territory creation, payments/zaps/boosts, messing around with rankings and analytics - all that worked great.

I have 2 more test areas to cover on this:

  • Uploads (I've been pretty mad trying to get s3 to work)
  • Connected wallets

Also:

  • Debugging what's up with the websocket in my dev setup
    • I think we need allowedDevOrigins: isProd ? undefined : [(new URL(process.env.PUBLIC_URL)).host], in next.config.js - unconfirmed still though
    • I get an occasional page reload - didn't get that before this patch, but may be related to the websocket

@Soxasora
Copy link
Copy Markdown
Member Author

Soxasora commented Apr 8, 2026

Thank you for the QA!

I think we need allowedDevOrigins: isProd ? undefined : [(new URL(process.env.PUBLIC_URL)).host], in next.config.js - unconfirmed still though

Ah yes, we do! I've added this in the custom domains PR today d466483, but I didn't make the same reasoning for this PR (or custom hosts in general...). localhost is by default an allowed dev origin though.

I should come up with a way to satisfy both normal sndev and custom domains. I'll check it out tomorrow first thing.


Uploads (I've been pretty mad trying to get s3 to work)

I think you just need to add your host to the allowed origins in docker/s3/cors.json. Then restart (or delete + start).

Comment thread next.config.js Outdated
Comment thread components/form.js Outdated
Copy link
Copy Markdown
Member

@huumn huumn left a comment

Choose a reason for hiding this comment

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

I'm not sure these should block merging, because afaict this would run fine in prod, but here's a few things I noticed:

  1. Dev hits the nextjs error boundary on checkMedia if capture is not running and media is checked. I didn't check if this happens in prod, but it's annoying in dev and should probably be fixed.
  2. One of the consequences of notifyOnNetworkStatusChange: true is that rerenders are happening when polling even if data doesn't change.
    • Example: create a new comment and watch the timeSince change
    • If only, this suggests I don't understand the consequences of notifyOnNetworkStatusChange: true and wonder what else might change.
  3. components/item-popover.js, components/sub-popover.js, components/user-popover.js, components/territory-form.js, wallets/client/hooks/logger.js all log AbortError still.
  4. Not a regression, but we pull deps from next/dist/* like NodeNextRequest which we got lucky didn't change between upgrades. Perhaps can be changed in a follow up.

@huumn huumn mentioned this pull request Apr 14, 2026
@Soxasora
Copy link
Copy Markdown
Member Author

Dev hits the nextjs error boundary on checkMedia if capture is not running and media is checked. I didn't check if this happens in prod, but it's annoying in dev and should probably be fixed.

Next.js now catches console.errors and show them as thrown errors in dev mode.

I mentioned this in this PR description, thinking that it was a new default behavior lol, but it seems like it's a bug on the pages router: vercel/next.js#75299

and media is checked

This is a behavior present on master:

  const trusted = useMemo(() => !!(srcSet || srcSetIntital) || IMGPROXY_URL_REGEXP.test(src) || MEDIA_DOMAIN_REGEXP.test(src), [srcSet, srcSetIntital, src])
  const [isImage, setIsImage] = useState((kind === 'image' || legacySrcSet?.video === false) && trusted)

If an image is uploaded, trusted will be true because at least MEDIA_DOMAIN_REGEXP.test(src) is true (being an upload), but when we convert markdown ![]() to Lexical MediaNode we always set kind: unknown triggering media checks.

We don't load the media client-side anymore to determine if it's an image or a video (or media at all), instead we always contact capture. I think we wanted to create a dedicated microservice for this.


One of the consequences of notifyOnNetworkStatusChange: true is that rerenders are happening when polling even if data doesn't change.

Just confirmed, basically it re-renders even on data.loading or data.networkStatus changes. By switching to false we're back to the original behavior, which we probably want so I'll switch back.


components/item-popover.js, components/sub-popover.js, components/user-popover.js, components/territory-form.js, wallets/client/hooks/logger.js all log AbortError still.

I'll handle AbortError in every catch for this, pretty bad that we have to do this.


Not a regression, but we pull deps from next/dist/* like NodeNextRequest which we got lucky didn't change between upgrades. Perhaps can be changed in a follow up.

Probably whatever we needed from NodeNextRequest we can get it from the public NextApiRequest for the pages router iirc.

@huumn
Copy link
Copy Markdown
Member

huumn commented Apr 14, 2026

Probably whatever we needed from NodeNextRequest we can get it from the public NextApiRequest for the pages router iirc.

It's mostly cookies parsing and in _app.js we use next/dist/client/router instead of next/router.

By switching to false we're back to the original behavior, which we probably want so I'll switch back.

I agree - that's the safest thing. Perhaps we can switch it to the default later (assuming it's worth it).

I mentioned this in this PR description, thinking that it was a new default behavior lol, but it seems like it's a bug on the pages router: vercel/next.js#75299

lol that's nasty

I'll handle AbortError in every catch for this, pretty bad that we have to do this.

It might be worth creating our own useDeferredQuery as a small wrapper around client.query, but also cool if not - borderline need for an abstraction.

@Soxasora
Copy link
Copy Markdown
Member Author

Soxasora commented Apr 14, 2026

It might be worth creating our own useDeferredQuery as a small wrapper around client.query, but also cool if not - borderline need for an abstraction.

I'm sure we won't need it if we fix the way we use useLazyQuery in some places. I'll come up with a follow-up PR refactor, for example the BaseSuggest's getSuggestions that is executed by useEffect when query !== undefined, can just be a useQuery with skip (avoiding AbortError)

…inal Apollo Client 3 behavior (don't re-render components unless data changes)
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

There are 4 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 85d3e80. Configure here.

Comment thread next.config.js
return devOrigins
}

module.exports = withPlausibleProxy({ src: 'https://plausible.io/js/pa-EScEhWlTi3E-sauvdFABb.js' })({
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unrecognized src option passed to withPlausibleProxy

Medium Severity

The src option passed to withPlausibleProxy is not a recognized configuration parameter. The valid options for withPlausibleProxy are subdirectory, scriptName, and customDomain (or scriptPath/apiPath in newer versions). The src value (https://plausible.io/js/pa-EScEhWlTi3E-sauvdFABb.js) is silently ignored, meaning the proxy rewrites are configured with default paths, not the custom script URL that was clearly intended to be used.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 85d3e80. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

export default function withPlausibleProxy(options: {
    /**
     * The site-specific script URL from your Plausible dashboard, e.g. https://plausible.io/js/pa-XXXXX.js.
     */
    src: string;
    /**
     * The local path for the proxied script. Defaults to /js/script.js.
     */
    scriptPath?: string;
    /**
     * The local path for the proxied API endpoint. Defaults to /api/event.
     */
    apiPath?: string;
}): (nextConfig: NextConfig) => NextConfig;

wdym??

Comment thread components/nav/common.js
@huumn
Copy link
Copy Markdown
Member

huumn commented Apr 14, 2026

I mentioned this in this PR description, thinking that it was a new default behavior lol, but it seems like it's a bug on the pages router: vercel/next.js#75299

I went ahead and switched mediaCheck to use console.warn. That's semantically more accurate anyway. We all tend to abuse console.error.

This is good to go now imo.

@huumn huumn merged commit 764b0c5 into stackernews:master Apr 14, 2026
7 checks passed
@Soxasora
Copy link
Copy Markdown
Member Author

Thank you! You're right about console.error, will be more precise.

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