Every long-lived Gatsby site eventually hits the same wall: the source plugin that pulls its content stopped being maintained. The CMS shipped a new API version, the plugin's last release was three years ago, npm install now fails on peer dependencies, or the plugin still works but pins an ancient gatsby-source-filesystem and blocks your Gatsby 5 upgrade. This is the single most common reason we see teams declare a Gatsby site "unmaintainable" — and it is usually a two-day fix, not a migration.
A source plugin is not magic. It is a Node file that fetches data and calls createNode for each record. If you can write a fetch call, you can replace it. This tutorial walks through doing that properly: schema stability, images, incremental builds, caching, and deletes.
1. Inventory what the plugin actually gives you
Before you delete anything, find out what your queries depend on. Run the site and open GraphiQL at http://localhost:8000/___graphql, then grep the codebase for the node types:
grep -rEo "all[A-Z][A-Za-z]+|\b[A-Z][A-Za-z]+\(id:" src/ gatsby-node.js | sort -u
Write down, for each type you use: the type name (allContentfulArticle), the fields queried, and anything that depends on plugin-created relationships — childMarkdownRemark, gatsbyImageData, linked references. Those three are where custom sourcing usually gets painful, and they are exactly the parts you want to keep identical so your page templates never change.
2. Source the data yourself
Put the fetch in gatsby-node.js (or a local plugin under plugins/my-source/, which is tidier and lets you keep options). The minimum viable version:
// gatsby-node.js
const NODE_TYPE = `Article`;
exports.sourceNodes = async ({
actions: { createNode },
createNodeId,
createContentDigest,
reporter,
}) => {
const timer = reporter.activityTimer(`sourcing ${NODE_TYPE}`);
timer.start();
const res = await fetch(`${process.env.CMS_URL}/api/articles?limit=1000`, {
headers: { Authorization: `Bearer ${process.env.CMS_TOKEN}` },
});
if (!res.ok) {
timer.panic(`CMS returned ${res.status} ${res.statusText}`);
return;
}
const { items } = await res.json();
for (const item of items) {
createNode({
...item,
id: createNodeId(`${NODE_TYPE}-${item.id}`),
remoteId: item.id,
parent: null,
children: [],
internal: {
type: NODE_TYPE,
contentDigest: createContentDigest(item),
},
});
}
timer.setStatus(`${items.length} ${NODE_TYPE} nodes`);
timer.end();
};
Three details matter more than they look:
timer.panicon a bad response. A source plugin that swallows an HTTP 500 produces a build with zero articles and a green checkmark. That is how sites silently deploy an empty blog. Fail the build instead.createNodeId, not the raw CMS id. Gatsby node IDs must be unique across every node type in the site. Prefixing by type avoids a collision the day you add a second source.- Keep
remoteId. You will want it for webhooks and for debugging which CMS record produced which page.
If the API paginates, loop until exhausted before creating nodes, and cap concurrency — hammering a CMS with 40 parallel requests from CI is a good way to get rate-limited mid-build.
3. Pin the schema so an empty API cannot change your types
Gatsby infers GraphQL types from the data it sees. If the CMS returns no drafts today, an optional field disappears from the schema and every query referencing it fails the build with Cannot query field "subtitle". Declare the schema explicitly:
exports.createSchemaCustomization = ({ actions: { createTypes } }) => {
createTypes(`
type Article implements Node {
remoteId: String!
title: String!
slug: String!
subtitle: String
body: String
publishedAt: Date @dateformat
tags: [String!]
author: Author @link(by: "remoteId", from: "authorId")
}
type Author implements Node {
remoteId: String!
name: String!
}
`);
};
@link rebuilds the relationships the old plugin gave you for free: article.author.name keeps working without changing a single template. @dateformat restores formatString on dates. Explicit types also make builds deterministic, which is worth it on its own — inference is a surprisingly large share of createSchemaCustomization time on big sites.
4. Markdown and images, the two things people miss
If your templates query childMarkdownRemark, recreate it by attaching a media-type child node rather than rewriting the templates:
const { createNode, createParentChildLink } = actions;
const mdNode = {
id: createNodeId(`${node.id}-md`),
parent: node.id,
children: [],
internal: {
type: `ArticleBody`,
mediaType: `text/markdown`,
content: item.body,
contentDigest: createContentDigest(item.body),
},
};
createNode(mdNode);
createParentChildLink({ parent: node, child: mdNode });
gatsby-transformer-remark picks that up by media type and gives you childArticleBody.childMarkdownRemark.html — still build-time rendered, still no runtime markdown parser.
For images, use createRemoteFileNode from gatsby-source-filesystem, then let gatsby-plugin-sharp produce gatsbyImageData:
const { createRemoteFileNode } = require(`gatsby-source-filesystem`);
// inside sourceNodes, after creating the article node
if (item.heroUrl) {
const fileNode = await createRemoteFileNode({
url: item.heroUrl,
parentNodeId: node.id,
createNode,
createNodeId,
cache,
store,
});
if (fileNode) node.heroImage___NODE = fileNode.id;
}
Downloading a few hundred originals on every cold build is slow but cached between builds; see our notes on cutting Gatsby build times if this becomes the bottleneck. If the CMS has its own image CDN and you do not need sharp transforms, skipping the download and storing URLs plus dimensions is dramatically faster — but you lose blur-up placeholders unless you generate them yourself.
5. Make rebuilds incremental with the cache and node touching
Re-fetching everything on every gatsby develop save is miserable. Use the plugin cache to store a sync token or updatedSince timestamp, and touchNode the records you did not refetch so Gatsby does not garbage-collect them:
exports.sourceNodes = async ({ actions, cache, getNodesByType, ...rest }) => {
const { touchNode, createNode, deleteNode } = actions;
const lastSync = await cache.get(`last-sync`);
for (const node of getNodesByType(NODE_TYPE)) touchNode(node);
const changed = await fetchArticles({ updatedSince: lastSync });
for (const item of changed.items) createNode(buildNode(item, rest));
for (const id of changed.deletedIds) {
const stale = rest.getNode(rest.createNodeId(`${NODE_TYPE}-${id}`));
if (stale) deleteNode(stale);
}
await cache.set(`last-sync`, changed.syncedAt);
};
Deletes are the step everyone forgets. Without handling deletedIds, an article unpublished in the CMS lives forever in the local cache and keeps rendering a page — until CI runs with a cold cache and the page vanishes, which is how you get a 404 nobody can reproduce locally. If your CMS has no delete feed, fetch the full id list (cheap) and diff it against getNodesByType.
6. Verify before you delete the old plugin
Run both sourcing paths side by side once, and diff the output:
# with the old plugin
npx gatsby build && mv public public-before
# with the custom source
npx gatsby clean && npx gatsby build && mv public public-after
diff -rq public-before public-after | grep -v "webpack\|page-data\|app-" | head -50
Identical HTML for every page means the replacement is genuinely a drop-in. Expect diffs only in hashed asset names. Also assert a floor on page count in CI so a partially failed fetch cannot ship:
// gatsby-node.js
exports.onPostBuild = ({ getNodesByType, reporter }) => {
const count = getNodesByType(`Article`).length;
if (count < 50) reporter.panic(`only ${count} articles sourced — refusing to deploy`);
};
What you gain
Roughly 120 lines of code you own, no peer-dependency roulette, and the CMS upgrade path becomes "change a URL" instead of "wait for a maintainer who left three years ago." It also unblocks Gatsby 5, React 19, and Node 22 upgrades that an abandoned plugin was pinning in place.
It is worth being honest about the trade-off: you now own rate limits, retries, and schema drift. That is a fair deal for a stable site, and a bad deal if you were already planning to move off Gatsby — in that case, spend the two days on the migration instead.
Stuck on an abandoned source plugin, a CMS API deprecation, or a Gatsby upgrade it is blocking? Get in touch — we scope this kind of work as a fixed-price rescue.