Every few months a client asks the same question in a different form: "our security scanner / our design system / a new dependency wants React 19 — can we upgrade the Gatsby site?" And underneath it, the real question: is this a two-hour bump or a two-week yak shave?
The short answer for Gatsby 5 in 2026: React 19 is not officially supported, it can be forced, and forcing it is only worth it when something you actually need requires it. Node is the easier half of the story, and the one most sites are overdue on. This tutorial walks the whole upgrade as we run it on client projects: how to find out in advance what will break, how to do the Node bump first, how to force React 19 if you must, and how to tell — with evidence — that the result is safe to deploy.
Start by writing down why
This matters more than any of the code below, because the answer decides whether you should stop reading.
Legitimate reasons to move to React 19 on Gatsby:
- A dependency you cannot replace ships React 19-only peer requirements (some newer component libraries and headless UI kits now do).
- You are mid-migration to Next.js or Astro and want the shared component library on one React version across both apps.
- You need a React 19 API —
use, the new form actions, ref-as-prop — in components that will outlive the Gatsby site.
Bad reasons: a dependency dashboard is yellow, or "we should be current". Gatsby 5 is in maintenance. Nobody is shipping a Gatsby release that makes React 19 first-class. Pushing a framework past what it supports buys you a build you have to babysit, and the payoff is zero if nothing needed it.
Node is different. Old Node versions fall out of security support, CI images stop offering them, and npm install starts failing on transitive packages that raise their engines floor. That upgrade you do want.
Step 1: audit before you touch anything
Get the facts out of the tree first. Three commands, five minutes:
# What React version is actually installed, and who pulled it in?
npm ls react react-dom
# Which of your dependencies declare a React peer range?
npm ls --all --json 2>/dev/null \
| node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{
const seen=new Set();
(function walk(n){for(const [k,v] of Object.entries(n.dependencies||{})){
if(v.peerDependencies&&v.peerDependencies.react&&!seen.has(k+v.peerDependencies.react)){
seen.add(k+v.peerDependencies.react);console.log(k.padEnd(45),v.peerDependencies.react);
} walk(v);}})(JSON.parse(s));
})"
# What does Gatsby itself want?
node -p "require('./node_modules/gatsby/package.json').peerDependencies"
node -p "require('./node_modules/gatsby/package.json').engines"
The peer-dependency list is the real scope of the job. Sort it into three buckets:
- Ranges that already allow 19 (
^18 || ^19,>=18). Free. - Packages with a newer release that allows 19. Cost: a version bump each, plus whatever their own breaking changes are.
- Packages pinned to
^18with no maintained successor. These are the decision points. Every one of them is either a replacement, a fork, or a reason to abandon the upgrade.
Bucket 3 on a typical Gatsby 5 site includes react-helmet (unmaintained; you should be on the Gatsby Head API regardless), older animation and carousel libraries, and any gatsby-plugin-* that renders React in wrapRootElement.
Step 2: do the Node upgrade on its own branch
Ship this separately. It is lower risk, it is independently valuable, and mixing it with a React change makes bisecting a broken build miserable.
Pin the version everywhere so local, CI, and the host agree:
# .nvmrc
22.20.0
// package.json
{
"engines": { "node": ">=20.0.0" },
"volta": { "node": "22.20.0" }
}
# .github/workflows/build.yml (excerpt)
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'
- run: npm ci --ignore-scripts
- run: npm rebuild sharp
- run: npx gatsby build
Then set the same version on the host — Netlify NODE_VERSION, Vercel's project setting, or the nodejs_version in your Amplify/Cloudflare build config. A Gatsby build that works locally and fails in CI is, nine times out of ten, two different Node majors.
What actually breaks on a Node major bump, in the order we hit it:
sharpprebuilt binaries. Native modules are compiled per Node ABI. Deletenode_modules, reinstall, and if the resolver still hands you a stale binary,npm rebuild sharp --verbose. On very old Gatsby trees you may need to bumpsharpitself, which means checkinggatsby-plugin-sharp's expectations.- OpenSSL and hashing. Older webpack and PostCSS chains call
crypto.createHash('md4'), which newer Node refuses. The legacy escape hatch isNODE_OPTIONS=--openssl-legacy-provider; the correct fix is bumping the offending package. - Heap flags. Big Gatsby builds still need
NODE_OPTIONS=--max-old-space-size=4096or more. That flag is not inherited by every CI runner; set it in the build environment, not just your shell. - Punycode and other deprecation noise. Loud, mostly harmless, worth silencing at the source package rather than muting warnings.
Verify with a build and a diff, not a vibe:
npx gatsby clean && npx gatsby build
find public -name '*.html' | wc -l # compare with the pre-upgrade count
A dropped page count is the single most useful red flag in any Gatsby upgrade. Record it before you start.
Step 3: clear the React 18-only blockers
Before forcing anything, remove the packages that will fight you.
react-helmet → Gatsby Head API. The Head API is built into Gatsby 5, renders into the static HTML, and has no React peer of its own:
// src/templates/post.js
export const Head = ({ data, location }) => (
<>
<title>{data.markdownRemark.frontmatter.title}</title>
<meta name="description" content={data.markdownRemark.frontmatter.description} />
<link rel="canonical" href={`https://www.example.com${location.pathname}`} />
</>
);
String refs, legacy context, ReactDOM.render in custom code. React 19 removed all of them. Find them before the compiler does:
npx eslint . --rule '{"react/no-string-refs":"error"}' --ext .js,.jsx,.ts,.tsx
grep -rn "ReactDOM.render\|ReactDOM.hydrate\|findDOMNode\|propTypes" src/
propTypes on function components is now ignored rather than enforced — not a crash, but if it was your only prop validation you have quietly lost it. Migrating those components to TypeScript is the durable fix.
react-test-renderer. Deprecated. Move component tests to Testing Library before the upgrade so you have a working test suite to judge the upgrade with.
Land all of that on React 18. Each change is independently correct, and afterwards the actual version bump is small.
Step 4: force React 19 (if you still want to)
Gatsby's peer range will not accept it, so you override the resolution. With npm:
{
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"overrides": {
"react": "$react",
"react-dom": "$react-dom"
}
}
The $react syntax points the override at your own dependency spec, so you do not have to keep two version numbers in sync. pnpm uses pnpm.overrides, Yarn Berry uses resolutions. Then:
rm -rf node_modules .cache public
npm install
npm ls react react-dom # expect a single 19.x, no "invalid" markers on the tree
npx gatsby build
Understand exactly what you have just done: you have told the package manager to lie to Gatsby about a peer requirement. There is no support path if this breaks, and npm ci will happily reproduce the lie in CI. Put a comment in package.json and a line in the README saying why the override exists, or the next developer will "clean it up" and spend a day working out why.
What we see fail, and what to do about it:
useLayoutEffectand hydration warnings during SSR. Usually real hydration mismatches that React 18 tolerated more quietly. Fix the component; do not suppress the warning.react-dom/serverAPI drift. Gatsby's SSR calls intoreact-dom/serverinternals. If a plugin wraps or patches SSR rendering, this is where it dies. Removing the plugin is often the only fix.- Third-party components rendering blank. Usually a library shipping the old
ReactDOM.renderpath. No workaround; replace it. gatsby developfine,gatsby buildbroken. Different rendering path. Always judge onbuildplusgatsby serve, never on the dev server.
If any of the first three land on a plugin you cannot remove, that is your answer: stay on React 18 and put the effort into migrating off Gatsby instead. It is the same budget spent on something that ends.
Step 5: prove it, in CI
An upgrade you cannot verify is not finished. The minimum gate we add to the pipeline:
- run: npx gatsby build
- run: |
COUNT=$(find public -name '*.html' | wc -l)
echo "pages: $COUNT"
test "$COUNT" -ge "$EXPECTED_PAGES"
- run: npx gatsby serve --port 9000 &
- run: npx wait-on http://localhost:9000
- run: npx playwright test
And in the Playwright suite, one test that catches the failure mode React upgrades actually produce — a page that renders server-side and then dies on hydration:
test('no console errors on hydration', async ({ page }) => {
const errors: string[] = [];
page.on('console', (m) => m.type() === 'error' && errors.push(m.text()));
page.on('pageerror', (e) => errors.push(e.message));
await page.goto('/');
await page.getByRole('link', { name: 'Services' }).click(); // client-side nav
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
expect(errors).toEqual([]);
});
Server-rendered HTML being correct proves nothing about interactivity, and interactivity is exactly what a React major touches. Add a visual diff over your top ten templates if the site has a design system; it costs an afternoon and catches the silent layout regressions.
The honest recommendation
Do the Node upgrade now — it is cheap, it is security-relevant, and it keeps installs working. Do the React 18-only cleanup now too: Head API, no string refs, Testing Library, no react-test-renderer. Those are pure wins and they make any future move easier, whichever framework you land on.
Force React 19 onto Gatsby 5 only when a dependency you need leaves you no choice, and treat it as a bridge with a known end date, not a destination. If you find yourself overriding peer dependencies to keep a framework alive, the upgrade you are really planning is the migration.
If you would like a second opinion on whether a Gatsby upgrade or a migration is the better spend on your codebase — or someone to run the audit and the CI gates above — get in touch.