Most of the Gatsby upgrade advice written in the last two years assumes you are on v4 or v5 and arguing about React 19. A large share of the sites we are called into are not. They are on Gatsby 2 or 3, last built successfully in 2021 or 2022, pinned to Node 14 or 16, and they fall over the moment anyone runs npm install on a current machine. The site still serves fine — it is static HTML — but nobody can change it.
The question is always the same: can this be upgraded, or should it be rebuilt? This tutorial is the path we actually follow, in order, with the decision points where we stop and recommend a rewrite instead.
Step 0: get the old build green before you change anything
Do not start by bumping versions. Start by proving the current code builds, so that every later failure has one cause.
git switch -c upgrade/gatsby-5
cat package.json | grep -E '"(gatsby|react|node)"'
# match the Node version the site was built with
nvm install 14 && nvm use 14
rm -rf node_modules
npm ci --legacy-peer-deps # or yarn install --frozen-lockfile
npx gatsby clean && npx gatsby build
If npm ci fails because the lockfile is lockfileVersion 1 and your npm is 10, install an older npm inside the Node 14 shell (npm i -g npm@6). If the build still fails, the usual culprits are:
node-gyp/sharpbinaries.gatsby-plugin-sharpon Gatsby 2 compiles against libvips headers that no longer ship. Use a Docker image pinned tonode:14-bullseyerather than fighting your host toolchain.- A dead network dependency.
gatsby-source-*plugins pointed at an API endpoint that was retired. You cannot fix this by upgrading; note it and move on. digital envelope routines::unsupported. That is OpenSSL 3 under a newer Node; it confirms you are not actually on the old Node.
Commit the lockfile and a short UPGRADE.md recording the exact Node, npm, and OS that produced a green build. That is your baseline. Also archive the build output — cp -r public baseline-public — because you will diff against it later.
Step 1: inventory the plugins before you inventory the code
The Gatsby core upgrade is rarely what sinks these projects. The plugins are. Produce the list first:
node -e "
const p=require('./package.json');
Object.keys(p.dependencies||{}).filter(d=>d.startsWith('gatsby'))
.forEach(d=>console.log(d, p.dependencies[d]));
"
Then, for each plugin, check three things on npm: the last publish date, the declared peerDependencies on gatsby, and whether it has a v5-compatible major. Sort them into four buckets:
| Bucket | Meaning | Action |
|---|---|---|
| Maintained | Published in the last 12 months, peerDependencies: { gatsby: "^5" } | Bump with core |
| Absorbed | Functionality moved into Gatsby itself | Delete and rewrite the usage |
| Replaceable | Dead, but an equivalent exists | Swap, budget a day each |
| Orphaned | Dead, no equivalent, non-trivial behaviour | This is your decision point |
The "absorbed" bucket is bigger than people expect and it is free wins: gatsby-image became gatsby-plugin-image, react-helmet is replaced by the built-in Head API, gatsby-plugin-typescript is built in, and most gatsby-plugin-page-creator custom usage is now the File System Route API.
If the orphaned bucket contains more than two or three plugins carrying real logic — a bespoke source plugin against a CMS you still use, a theme nobody has the source for, an image pipeline built on gatsby-image's fluid fragments wired into hundreds of components — stop and price a rebuild on a current framework against the upgrade. Past roughly a third of the component tree needing edits, the rebuild usually wins, and you get to choose the destination rather than inheriting it.
Step 2: go v2 → v3 → v4 → v5, one major at a time
The temptation is to set "gatsby": "^5" and spend a week reading stack traces with no idea which change caused which. Don't. Each major has an official migration guide with a codemod or two, and taking them in order means every failure has an obvious owner. Commit after each.
# v2 -> v3
npm install gatsby@3 react@17 react-dom@17
npx gatsby clean && npx gatsby build
The things that bite at each step, in our experience:
v2 → v3. GraphQL queries become stricter; gatsby-image is deprecated in favour of gatsby-plugin-image; webpack 5 arrives, so any plugin reaching for Node core modules (fs, path, crypto) in browser code now errors instead of silently polyfilling. Fix those with a guard rather than re-adding polyfills:
// gatsby-node.js
exports.onCreateWebpackConfig = ({ stage, actions }) => {
if (stage === 'build-html' || stage === 'develop-html') {
actions.setWebpackConfig({
module: { rules: [{ test: /bad-browser-only-lib/, use: ['null-loader'] }] },
});
}
};
v3 → v4. The big one. Gatsby 4 introduced SSR and Deferred Static Generation, and with them a stricter execution model: gatsby-node.js APIs run in parallel workers, so module-level mutable state shared between createPages calls stops working. Also createPage now wants context to be serialisable — no functions, no class instances. And the Node 12 floor is dropped.
v4 → v5. The File System Route API matures, gatsby-plugin-image is mandatory for modern image handling, Slices land, and GraphQL sort syntax changes shape. That last one touches every query:
# Gatsby <5
allMarkdownRemark(sort: { fields: [frontmatter___date], order: DESC }) { ... }
# Gatsby 5
allMarkdownRemark(sort: { frontmatter: { date: DESC } }) { ... }
There is a codemod (npx gatsby-codemods@latest sort-and-aggr-graphql <dir>) that handles the mechanical cases. Run it, then grep for ___ to find what it missed — template literals and queries built as strings are always left behind.
Only after v5 is green do you touch React 19 and Node 22. Upgrading the framework and the runtime in the same commit is how a two-week job becomes a two-month one.
Step 3: replace the three things that always need replacing
Nearly every v2/v3 site carries the same three legacy patterns.
gatsby-image → gatsby-plugin-image. The query fragments change and so does the component:
// before
import Img from 'gatsby-image';
<Img fluid={data.file.childImageSharp.fluid} alt="" />
// after
import { GatsbyImage, getImage } from 'gatsby-plugin-image';
const image = getImage(data.file.childImageSharp.gatsbyImageData);
<GatsbyImage image={image} alt="" />
# before: ...GatsbyImageSharpFluid
# after:
childImageSharp {
gatsbyImageData(width: 800, placeholder: BLURRED, formats: [AUTO, WEBP, AVIF])
}
react-helmet → the Head API. Helmet is unmaintained and drops tags under React 18 hydration. Anything exported as Head from a page or template is rendered into the static HTML at build time, which is what you want anyway:
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}`} />
</>
);
Manual createPages → File System Route API. If gatsby-node.js is 400 lines of createPage loops, much of it can be deleted in favour of src/pages/{MarkdownRemark.fields__slug}.js. Do this after the version upgrade is green, not during — it changes URLs if you get the slug field wrong.
Step 4: diff the output, not just the build log
A green gatsby build proves nothing about whether the pages are the same. Compare against the baseline you archived in step 0:
# same set of routes?
(cd baseline-public && find . -name 'index.html' | sort) > /tmp/before.txt
(cd public && find . -name 'index.html' | sort) > /tmp/after.txt
diff /tmp/before.txt /tmp/after.txt
# same rendered text on a sample of pages?
for p in index blog about contact; do
diff <(sed 's/<[^>]*>//g' "baseline-public/$p/index.html" 2>/dev/null) \
<(sed 's/<[^>]*>//g' "public/$p/index.html" 2>/dev/null) \
> /dev/null || echo "DIFF: $p";
done
Route-set drift is the dangerous one, because it is silent and it costs rankings. Trailing slashes changed (gatsby-plugin-force-trailing-slashes is obsolete; Gatsby 5 has a trailingSlash config option), a slug field resolved differently, or pagination produced page/2 instead of 2. Fix the generation if you can; where you cannot, write redirects and keep the old URLs reachable.
Then lock it in so it cannot regress: a Playwright smoke test over the top twenty URLs, a build assertion that the page count is within a few percent of the baseline, and a Lighthouse or field-data check on the templates that changed most.
Step 5: write down what you now know
The reason the site rotted is almost never that Gatsby is hard. It is that the one person who could build it left, and nothing recorded how. Before you close the ticket, commit a README section covering: the Node and package manager versions (ideally enforced via "engines" and a .nvmrc), which environment variables the build needs and where they live, which plugins are forks or patched (patch-package entries deserve a comment each), how to get a content preview, and what to do when the build breaks at 2am.
Add a scheduled CI build — weekly, on an unchanged main — so dependency rot surfaces as a failing scheduled job rather than as an emergency the next time someone needs a copy change.
When to stop and rebuild instead
Be honest about these. Stop the upgrade and price a rebuild if:
- The CMS or API the site sources from no longer exists, and there is no export.
- The orphaned-plugin bucket contains bespoke logic nobody can describe.
- The site needs server-rendered or personalised content on most routes, which is a poor fit for the architecture regardless of version.
- The upgrade estimate exceeds roughly half a rebuild estimate — at that ratio you are paying nearly rebuild price for a codebase you did not choose.
Otherwise, the upgrade is usually the cheaper answer, and the staged path above keeps it predictable. A Gatsby 2 marketing site with ten plugins and fifty templates is typically a week or two of work; the output is a site anyone can build again.
If you have a Gatsby 2 or 3 site nobody can build anymore and you want a fixed-scope assessment of upgrade-versus-rebuild before committing budget, get in touch — our consultants do this audit regularly.