Fail the build rather than publish the fallback
A CMS fetch that falls back to fixtures is convenient in development and catastrophic in production, where it would have replaced fifteen live posts with two. The fix is not removing the fallback. It is asserting that it did not fire.
.png)
Content lives in a hosted CMS. The site is static. Somewhere between those two facts is a fetch, and the interesting decision is not where it runs but what it does when it fails.
The problem
Fetching CMS content in the browser at runtime means every reader waits for a network round trip to a third party, the content is invisible to anything that does not execute JavaScript, and an outage at the CMS is an outage on the site. For content that changes a few times a month, all of that is paid continuously for no benefit.
So the fetch happens once, at build time. A script queries the CMS, converts what comes back into the shape the app renders, and writes it into the build output as static JSON. The site ships knowing its own content.
That much is unremarkable. The consequence is the interesting part: the build now depends on credentials and a third-party API, and builds run in places where either can be missing. A developer cloning the repository has no CMS credentials. Neither does a pull request from a fork. Both still need the site to build.
The obvious answer is a fallback. Check in a couple of representative documents as fixtures, use them when the credentials are absent, and local development works offline.
if (PROJECT_ID) {
posts = await fromSanity();
} else {
posts = await fromFixtures();
}This is where it gets dangerous, and the danger is not in that code.
The fallback is correct in one environment and destructive in another
The branch is chosen by whether an environment variable is set. That is exactly the thing most likely to be missing in a misconfigured production build. A deploy that loses its CMS credentials does not fail. It succeeds, fetches nothing, falls back to the two checked-in fixtures, and publishes a site with two blog posts where there were fifteen.
Nothing about that is anomalous from the build's point of view. Every step exits zero. The artefact is valid. It is simply wrong, and it overwrites a correct site.
The reflex is to delete the fallback so this cannot happen. That trades one failure for another: now a fork's pull request cannot build, and local development needs credentials to render a page of text.
The better move is to keep the fallback and assert that it did not fire, in the one place where firing is unacceptable:
count=$(ls build/content/blog/*.json | grep -vc 'index.json')
if [ "$count" -le 2 ]; then
echo "Only $count post(s) built - this looks like the fixture fallback."
exit 1
fiCrude on purpose. It counts files and compares against the number of fixtures. It runs only on the production path, so a fork still builds with fixtures and a deploy cannot.
The general shape: a graceful degradation is a decision about which environment you are in, and the code taking that decision usually cannot tell. So the environment that must not degrade should be the one that checks. Anywhere you have a default that is safe in development, the question is what asserts it did not reach production.
The other half: fail loudly inside the fetch
The guard above catches a missing credential. It does not catch a fetch that runs and goes wrong, because that path should never reach a fallback at all:
const res = await fetch(url);
if (!res.ok) throw new Error(`Sanity query failed: ${res.status} ${res.statusText}`);
const { result } = await res.json();
if (!Array.isArray(result)) throw new Error('Sanity returned no result array');The fallback is reachable only when credentials are absent. A CMS outage, a malformed query or a document in an unexpected shape throws, and the build fails. There is no path where a transient error quietly produces a smaller site.
That distinction is worth being deliberate about. "The credentials are not here, use fixtures" is a statement about the environment. "The API returned a 503" is a statement about the world, and falling back on it turns an outage into a silent content deletion.
What it costs
A rebuild per publish. Content is baked in, so publishing does nothing until something builds. That needs a webhook from the CMS into CI, which is another moving part with its own credentials and its own failure mode, and its failure looks like a post that never appears.
Fixtures drift. Two checked-in documents are a sample of the real content, and they age. They are exercised on every pull request, so a schema change that breaks them is caught, but a schema change the fixtures do not cover is not.
Two representations of the same content. The CMS stores markdown; the app renders a typed block tree. The conversion happens at build time, which means a markdown feature nobody implemented is silently dropped rather than rendered badly. Better than the alternative, still silent.
Where it breaks
The guard is a threshold, not a check. It compares a count against the number of fixtures. If the fixture count ever grows past that threshold, the guard passes on a fixture build. Nothing ties the two numbers together, and the comment saying so is the only thing keeping them in sync.
It only protects the blog. The same build fetches other content with no equivalent assertion. The pattern was applied where it was learned, not everywhere it applies.
A partial fetch passes. The guard asserts more than two posts, not that the count matches what the CMS holds. If a query returned three of fifteen, that ships.
Nothing checks the content is newer than the last deploy. A build that succeeds with correct but stale content is indistinguishable from a good one, which matters more than it sounds now that publishing is what triggers builds.
More on what the documentation covers. FRED itself is at biofred.us.
References
Sanity. GROQ query language.
Netlify. Build hooks.
GitHub. Repository dispatch event.