Gatsby Cloud's shutdown took more than hosting with it. The part clients miss most is not the build farm — Netlify, Vercel, and Cloudflare all build Gatsby fine — it is the editorial loop: an editor saves a draft in Contentful or Sanity, clicks "Open preview", and sees the real page in a few seconds. Rebuild that badly and your content team goes back to emailing screenshots and asking a developer to deploy.
This tutorial covers how we restore that loop on Gatsby 5 without Gatsby Cloud: a long-running preview server for drafts, webhook-triggered production builds with sane debouncing, and a deploy gate so a bad content change cannot ship a broken site. The examples use Contentful and Netlify because that is the most common pairing we see, with notes for Sanity and Cloudflare.
The two halves of the problem
Editors need two different things and they need different machinery:
- Draft preview — unpublished content, visible immediately, only to logged-in editors. This cannot be a static build; a full Gatsby build of a real site takes minutes and drafts change every few seconds.
- Publish — published content, rebuilt and deployed to the public site. This should be a static build, and it should be batched, not one build per keystroke.
Gatsby Cloud blurred these together behind one UI. Separating them explicitly is what makes the replacement understandable.
Part 1: a preview server for drafts
gatsby develop is the preview server. It has a working data layer, hot reload, and — importantly — it can source draft entries when you tell it to. Run it as a long-lived process on a small box behind auth, not as a build.
Step 1: make the source plugin draft-aware
Contentful exposes drafts through the Preview API, which is a different host and a different token:
// gatsby-config.js
const isPreview = process.env.GATSBY_IS_PREVIEW === 'true';
module.exports = {
plugins: [
{
resolve: 'gatsby-source-contentful',
options: {
spaceId: process.env.CONTENTFUL_SPACE_ID,
accessToken: isPreview
? process.env.CONTENTFUL_PREVIEW_TOKEN
: process.env.CONTENTFUL_DELIVERY_TOKEN,
host: isPreview ? 'preview.contentful.com' : 'cdn.contentful.com',
environment: process.env.CONTENTFUL_ENVIRONMENT || 'master',
},
},
],
};
For Sanity the equivalent is watchMode: true plus a read token in gatsby-source-sanity, which gives you live listener-driven updates in develop:
{
resolve: 'gatsby-source-sanity',
options: {
projectId: process.env.SANITY_PROJECT_ID,
dataset: process.env.SANITY_DATASET,
watchMode: process.env.GATSBY_IS_PREVIEW === 'true',
overlayDrafts: process.env.GATSBY_IS_PREVIEW === 'true',
token: process.env.SANITY_READ_TOKEN,
},
}
overlayDrafts is the flag that does the real work: draft documents replace their published counterparts in the data layer, so your existing templates render them with no changes.
Step 2: mark preview builds so nobody confuses them with production
Two safeguards, both cheap. Block indexing, and put a visible banner on the page.
// src/components/preview-banner.js
export const PreviewBanner = () =>
process.env.GATSBY_IS_PREVIEW !== 'true' ? null : (
<div role="status" style={{ background: '#7a2f00', color: '#fff', padding: '.5rem 1rem' }}>
Preview build — unpublished content, not the live site.
</div>
);
// src/components/seo.js (rendered from the Head API)
export const Head = () => (
<>
{process.env.GATSBY_IS_PREVIEW === 'true' && (
<meta name="robots" content="noindex, nofollow" />
)}
</>
);
Remember that only GATSBY_-prefixed env vars reach browser code, which is why the flag is named that way.
Step 3: run it as a service
On a small VM (1–2 vCPU is enough for most sites; image-heavy sites want 4GB+ RAM):
# /etc/systemd/system/gatsby-preview.service
[Unit]
Description=Gatsby preview server
After=network.target
[Service]
WorkingDirectory=/srv/preview
EnvironmentFile=/srv/preview/.env.preview
Environment=NODE_ENV=development
ExecStart=/usr/bin/npm run develop -- --host 127.0.0.1 --port 8000
Restart=always
RestartSec=10
User=preview
[Install]
WantedBy=multi-user.target
Put it behind nginx with HTTP basic auth or your identity provider — never expose a preview server publicly, because it serves unpublished content and a preview token sits in its environment.
location / {
auth_basic "Preview";
auth_basic_user_file /etc/nginx/preview.htpasswd;
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # websocket for hot reload
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
The two proxy_set_header lines are the ones people forget: without the websocket upgrade, hot reload silently stops and editors report "preview is stuck".
Step 4: refresh the data layer on content change
gatsby develop will not notice a Contentful edit on its own. Gatsby ships an endpoint for exactly this:
# .env.preview
ENABLE_GATSBY_REFRESH_ENDPOINT=true
GATSBY_IS_PREVIEW=true
GATSBY_PREVIEW_REFRESH_TOKEN=some-long-random-string
With that set, POST /__refresh re-runs sourcing without restarting the process. Point a Contentful webhook at it (Settings → Webhooks), triggered on entry publish and auto-save, with the token in the header:
POST https://preview.example.com/__refresh
x-gatsby-refresh-token: some-long-random-string
Nginx should allow that path without basic auth but require the token, or Contentful's request will bounce off the auth prompt:
location = /__refresh {
auth_basic off;
proxy_pass http://127.0.0.1:8000/__refresh;
}
Step 5: deep-link from the CMS
The last mile is editors not having to hunt for the URL. In Contentful, add a content preview under Settings → Content preview with a URL pattern per content type:
https://preview.example.com/blog/{entry.fields.slug}/
In Sanity, set document.productionUrl in sanity.config.ts so the Open Preview button resolves the same path. Now the loop is: save → webhook → __refresh → hot reload → the editor sees it.
Part 2: production builds on a webhook, batched
The naive setup is a Contentful "publish" webhook pointed at a Netlify build hook. It works until an editor publishes twelve entries in a row and you queue twelve builds.
Netlify and Cloudflare both cancel superseded queued builds, but you still pay in build minutes and in noise. A tiny debounce function in front of the build hook is worth the twenty lines:
// netlify/functions/cms-webhook.js
const STORE_KEY = 'pending-build';
export default async (req, context) => {
if (req.headers.get('x-webhook-secret') !== Netlify.env.get('CMS_WEBHOOK_SECRET')) {
return new Response('forbidden', { status: 403 });
}
const { getStore } = await import('@netlify/blobs');
const store = getStore('build-debounce');
const last = Number((await store.get(STORE_KEY)) || 0);
const now = Date.now();
const WINDOW_MS = 5 * 60 * 1000;
if (now - last < WINDOW_MS) {
return new Response('debounced', { status: 202 });
}
await store.set(STORE_KEY, String(now));
await fetch(Netlify.env.get('BUILD_HOOK_URL'), { method: 'POST' });
return new Response('build triggered', { status: 202 });
};
Trade-off to be explicit about with the content team: a five-minute window means a publish can take up to five minutes plus build time to go live. Most editorial teams prefer that to unpredictable queueing; newsrooms do not, and for them you either drop the window or add a "publish now" build hook they can hit directly.
If your CMS supports it, scope the webhook to the content types that actually affect pages. A change to an internal-only taxonomy entry should not rebuild the site.
Part 3: incremental sourcing, so the build stays short
A webhook-triggered build that re-downloads the entire CMS on every run defeats the point. Both Contentful and Sanity source plugins support delta sourcing, but only if Gatsby's cache survives between builds — and on most CI hosts it does not by default.
On Netlify, add the cache plugin:
# netlify.toml
[[plugins]]
package = "netlify-plugin-gatsby-cache"
[build]
command = "npm run build"
publish = "public"
[build.environment]
NODE_VERSION = "20"
On generic CI, cache .cache/ and public/ keyed on the lockfile, and be prepared to invalidate on gatsby-config.js changes:
- uses: actions/cache@v4
with:
path: |
.cache
public
key: gatsby-${{ hashFiles('package-lock.json', 'gatsby-config.js') }}-${{ github.sha }}
restore-keys: |
gatsby-${{ hashFiles('package-lock.json', 'gatsby-config.js') }}-
Watch the build log. gatsby-source-contentful reports how many entries it fetched; if that number equals your full entry count on every build, the cache is not being restored and delta sourcing is not happening.
Part 4: a gate so content cannot break production
Static builds have a genuine failure mode that dynamic sites do not: an editor can crash the build. A missing required field, a null reference, an unexpected embedded entry type — the page template throws, gatsby build exits non-zero, and nothing deploys.
Three defences, cheapest first:
Defend in the template. Any field an editor can leave empty is optional in your code, not in your assumptions:
const hero = data.page?.hero;
if (!hero?.image?.gatsbyImageData) return <TextOnlyHero title={data.page.title} />;
Fail the build loudly, in one place. Validate the shape after sourcing rather than discovering it inside a render:
// gatsby-node.js
exports.createPagesStatefully = undefined; // (unused, shown for contrast)
exports.createPages = async ({ graphql, reporter }) => {
const { data, errors } = await graphql(`
{ allContentfulPage { nodes { id title slug } } }
`);
if (errors) { reporter.panicOnBuild('page query failed', errors); return; }
const bad = data.allContentfulPage.nodes.filter((n) => !n.slug || !n.title);
if (bad.length) {
reporter.panicOnBuild(
`${bad.length} page entries are missing slug or title: ${bad.map((n) => n.id).join(', ')}`,
);
return;
}
// ...createPage calls
};
An editor reading "3 page entries are missing slug or title" in a build notification can fix it themselves. A React stack trace guarantees a support ticket.
Keep the last good deploy. Netlify and Cloudflare both keep the previous deploy live when a build fails, which is the single most valuable property of this architecture: a broken content change means the site is stale, not down. Verify it once, deliberately, by pushing a deliberately broken draft to a staging site — teams assume this and are occasionally wrong about their own configuration.
Wiring it together
The finished loop, end to end:
| Editor action | What happens |
|---|---|
| Save draft | CMS auto-save webhook → POST /__refresh → preview server re-sources → hot reload |
| Open preview | CMS deep-links to preview.example.com/<path> behind auth, banner + noindex |
| Publish | CMS publish webhook → debounce function → build hook |
| Build | Cache restored, delta sourcing, validation gate, deploy |
| Build fails | Previous deploy stays live, notification names the bad entries |
Two moving parts to monitor: the preview server (systemctl status, plus an uptime check on an authenticated path) and the webhook function's logs. When editors say "preview is broken", it is almost always one of three things — the systemd unit restarted and lost its warm cache, the websocket proxy headers are missing, or the preview token was rotated in the CMS and not in .env.preview.
What we would not rebuild
We do not recommend recreating Gatsby Cloud's per-branch preview deploys for content. Branch deploys for code changes are a solved problem on every host; per-content-change static previews were expensive even when Gatsby ran them, and a single warm develop server plus draft overlay covers the editorial need at a fraction of the cost and complexity.
If your editorial workflow has been degraded since Gatsby Cloud shut down — or you are choosing between rebuilding this and migrating the site off Gatsby entirely — get in touch and we will walk through both options with you.