Plenty of Gatsby sites we are called in to rescue have the same shape: WordPress in the back, gatsby-source-wordpress in the middle, a static front end on Netlify or Cloudflare. The content team is happy, marketing is happy, and then one day the build breaks — a plugin update changes the GraphQL schema, the sourcing step runs for forty minutes and times out, or an editor publishes a post and nothing appears on the site for two days.
Headless WordPress is still a reasonable content stack in 2026. What is no longer reasonable is treating the source plugin as a black box you never have to understand. This tutorial walks through the four decisions that keep a WordPress + Gatsby 5 pipeline maintainable: how sourcing actually works, how to stop full re-sources on every build, how to survive a schema you do not control, and how to get previews back without Gatsby Cloud.
1. Know which sourcing model you are on
There are three ways a Gatsby site pulls from WordPress, and the failure modes are completely different.
REST (wp-json). Old sites, usually Gatsby 2-era, often with hand-rolled sourceNodes. Slow and chatty, but dependency-light.
WPGraphQL via gatsby-source-wordpress v7. The common case. The plugin introspects your WPGraphQL schema at build time, mirrors it into Gatsby's data layer, and supports delta sourcing.
Your own sourceNodes against WPGraphQL. What we increasingly recommend for sites that have outgrown the plugin. More code, but you own the failure modes.
Find out which one you are on before you change anything:
npm ls gatsby-source-wordpress gatsby 2>/dev/null
grep -rn "wp-json\|graphql" gatsby-config.js gatsby-node.js | head -20
Then confirm the server side. In the WordPress admin, check that WPGraphQL is installed and active, and that any companion plugins (ACF integration, SEO integration, custom post type registration) match the field names your queries use. A surprising number of "Gatsby is broken" tickets are actually "someone deactivated a WordPress plugin".
Hit the endpoint directly so you know the contract without Gatsby in the way:
curl -s https://cms.example.com/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ posts(first: 2) { nodes { id slug title modified } } }"}' | jq .
If that request is slow, unauthenticated-blocked, or returns HTML, no amount of Gatsby configuration will help. Fix the CMS first.
2. Make the schema explicit so a WordPress change cannot blank your pages
The single most common WordPress + Gatsby outage is silent: a field goes from having values to being null everywhere (an ACF field renamed, a post type unregistered, a taxonomy emptied), Gatsby infers the type as nullable, queries keep passing, and pages render with empty sections. The build is green. The site is wrong.
Two guards fix this. First, define the types you depend on instead of relying on inference:
// gatsby-node.js
exports.createSchemaCustomization = ({ actions }) => {
actions.createTypes(`
type WpPost implements Node {
title: String!
slug: String!
uri: String!
date: Date! @dateformat
excerpt: String
}
`);
};
Non-null markers mean a missing title fails the build instead of shipping a blank <h1>. Keep truly optional fields optional — the point is to encode your real content contract, not to make every build fragile.
Second, assert on volume in createPages, because "zero posts" is a perfectly valid GraphQL response:
exports.createPages = async ({ graphql, reporter }) => {
const result = await graphql(`
{
allWpPost(sort: { date: DESC }) {
nodes { id uri slug }
}
allWpPage { nodes { id uri } }
}
`);
if (result.errors) throw result.errors;
const posts = result.data.allWpPost.nodes;
const pages = result.data.allWpPage.nodes;
const MIN_POSTS = Number(process.env.MIN_POSTS || 20);
if (posts.length < MIN_POSTS) {
reporter.panic(
`Sourced only ${posts.length} posts (expected >= ${MIN_POSTS}). ` +
`Refusing to build a site that would lose ${MIN_POSTS - posts.length} URLs.`
);
}
reporter.info(`Building ${posts.length} posts and ${pages.length} pages`);
// ...createPage calls
};
A floor like this has saved more client traffic than any performance work we have done. A deploy that quietly drops 400 URLs is an SEO incident that takes months to undo; a red build takes twenty minutes to diagnose.
3. Get sourcing time under control
gatsby-source-wordpress supports delta sourcing: after a full source, it asks WordPress only for what changed since the last run, provided the plugin's cache survived. In CI it usually does not, because .cache is thrown away between runs. That is why local builds take ninety seconds and CI takes thirty minutes.
Persist the cache, keyed so a plugin upgrade invalidates it:
# .github/workflows/build.yml
- uses: actions/cache@v4
with:
path: |
.cache
public
key: gatsby-${{ hashFiles('package-lock.json') }}-${{ github.sha }}
restore-keys: |
gatsby-${{ hashFiles('package-lock.json') }}-
Then tune what you ask for. Two settings matter more than the rest:
// gatsby-config.js
{
resolve: 'gatsby-source-wordpress',
options: {
url: process.env.WPGRAPHQL_URL,
schema: {
perPage: 20, // lower if WP times out on big queries
requestConcurrency: 5, // shared hosting will not thank you for 15
timeout: 120000,
},
type: {
Comment: { exclude: true },
Menu: { exclude: true },
User: { excludeFieldNames: ['extraCapabilities'] },
MediaItem: {
localFile: {
requestConcurrency: 25,
excludeByMimeTypes: ['video/mp4'],
},
},
},
},
}
Excluding node types you never query is the cheapest win available: comments and revisions are frequently the largest object count in a WordPress database and the most useless to a static build.
Media is the other half of the bill. localFile downloads every image through sharp, which is why a 6,000-image library turns into an hour of build time. On big libraries, stop downloading originals and let an image service handle transforms — point <img> at the WordPress URL or a CDN in front of it, keep gatsby-plugin-image for the handful of images that matter to LCP, and measure the difference:
GATSBY_CPU_COUNT=logical_cores npx gatsby build --verbose 2>&1 | tee build.log
grep -E "source and transform|createPages|Building static HTML" build.log
Read the per-phase timings before you optimise anything. If "source and transform nodes" is 80% of the build, caching and type exclusions are your fix. If image processing dominates, the media decision above is your fix. They are not interchangeable.
4. Restore previews and publish-to-deploy
With Gatsby Cloud gone, the editorial loop has to be rebuilt. The version that has held up for us is deliberately boring:
- A publish webhook, debounced. Add a small mu-plugin that POSTs to a CI build hook on
save_post,deleted_post, and term changes — then debounce on the receiving side so a bulk edit of forty posts triggers one build, not forty.
<?php
// wp-content/mu-plugins/gatsby-build-hook.php
add_action('transition_post_status', function ($new, $old, $post) {
if ($new !== 'publish' && $old !== 'publish') return;
if (wp_is_post_revision($post) || $post->post_type === 'revision') return;
wp_remote_post(getenv('GATSBY_BUILD_HOOK'), [
'blocking' => false,
'timeout' => 2,
'body' => wp_json_encode(['id' => $post->ID, 'type' => $post->post_type]),
]);
}, 10, 3);
-
A long-running preview instance. Run
gatsby develop(or a dedicated preview deploy) behind basic auth or an access policy, pointed at an authenticated WPGraphQL endpoint that can read drafts. Editors hit a "Preview" button that links tohttps://preview.example.com/?p=123. -
A refresh endpoint, not a restart. Set
ENABLE_GATSBY_REFRESH_ENDPOINT=trueon the preview server and have the mu-plugin also POST to/__refreshon draft saves so content re-sources without a cold restart. -
A deploy notification back to the editor. Post the build result into Slack with the post title. Editors who can see "published 3 minutes ago, live now" stop filing tickets asking whether the site is broken.
5. Decide, on purpose, whether to keep the plugin
Once the above is in place, the pipeline is stable — but it is worth asking every year whether gatsby-source-wordpress is still earning its place. The plugin buys you schema mirroring, media downloading, and delta sourcing. If you have already excluded most node types, stopped downloading media, and query twelve fields on three content types, you are paying a large dependency for very little.
The replacement is roughly a hundred lines: a paginated WPGraphQL fetch in sourceNodes, createNode calls with explicit types, touchNode for unchanged content, and createRemoteFileNode only for the images you actually process. You own the pagination, the error handling, and the schema. When WordPress changes, you get a clear failure in code you wrote rather than a schema-introspection mismatch three layers down a dependency tree.
Our rule of thumb: stay on the plugin while you use ACF, menus, Yoast fields, and preview integration heavily; move to your own sourcing when you are effectively using WordPress as a headless post store with a handful of fields.
A short checklist
- Query WPGraphQL directly with
curlbefore blaming Gatsby. createSchemaCustomizationfor every field a template depends on.- A
reporter.panicpage-count floor increatePages. .cachepersisted in CI, keyed on the lockfile.- Excluded node types; a deliberate decision about media downloads.
- Debounced publish webhook, an authenticated preview server,
/__refreshfor drafts. - A yearly review of whether the source plugin still pays for itself.
Run through that list on any inherited WordPress + Gatsby site and most of the usual emergencies stop happening. If you are staring at a forty-minute build or a preview loop that has been broken since Gatsby Cloud shut down, get in touch — it is a week of work, not a replatform.