+1 (415) 779-8456

Knowing When a Gatsby Site Breaks: Client Error Tracking, Source Maps, and Build Alerts

A static Gatsby site has a comforting failure story: the HTML is already built, so if something goes wrong it went wrong at build time, on your machine, where you saw it. That story is about two-thirds true — and the missing third is where the outages we get called about live.

Three things break on a live Gatsby site after a clean build:

  1. Client-side JavaScript throws. Hydration mismatches, a window access in a component that renders during hydration, a third-party widget that 404s, a ChunkLoadError after a deploy. The static HTML still paints, so the page looks fine, but navigation, forms, and search are dead.
  2. The build silently stops running. A scheduled rebuild fails, a CMS webhook stops firing, a token expires. Nobody notices for three weeks because the last successful deploy is still being served perfectly.
  3. The deploy target has an opinion. A redirect rule gets dropped, a header config is ignored by a new adapter, a form endpoint returns 500.

None of those show up in a CI test suite that passed, and none of them page anybody. This tutorial sets up the four pieces of monitoring that do: client error tracking with usable stack traces, build/deploy failure alerts, uptime and content checks against the real production URL, and a small amount of discipline about what is actually worth alerting on.

Everything below is for Gatsby 5 on Node 20+, and is deliberately vendor-light: the examples use Sentry because it is the most common choice, but the shape is the same for GlitchTip (self-hosted, Sentry-compatible), Rollbar, or Bugsnag.

1. Client error tracking, without the noise

Install the browser SDK. You do not need gatsby-plugin-sentry, which is unmaintained and predates the Gatsby 5 browser APIs; wire it up yourself in gatsby-browser.js so you control initialisation order.

npm i @sentry/browser
// gatsby-browser.js
import * as Sentry from '@sentry/browser';

export const onClientEntry = () => {
  if (!process.env.GATSBY_SENTRY_DSN) return;

  Sentry.init({
    dsn: process.env.GATSBY_SENTRY_DSN,
    environment: process.env.GATSBY_ENV || 'production',
    // The release MUST match the name you upload source maps under.
    release: process.env.GATSBY_COMMIT_SHA,
    tracesSampleRate: 0.1,
    // Only report errors coming from our own bundles.
    allowUrls: [/https:\/\/www\.example\.com/],
    ignoreErrors: [
      // Browser extensions and injected scripts
      'top.GLOBALS',
      /^ResizeObserver loop/,
      // Network noise you cannot act on
      'Failed to fetch',
      'NetworkError when attempting to fetch resource.',
      'AbortError',
    ],
    denyUrls: [
      /extensions\//i,
      /^chrome:\/\//i,
      /^moz-extension:\/\//i,
      /googletagmanager\.com/,
    ],
  });
};

GATSBY_-prefixed variables are inlined into the browser bundle at build time, so these must be set in the build environment, not at runtime. The DSN is not a secret — it is a public write-only endpoint — but treat your auth token (section 2) as one.

Two settings earn their keep immediately. allowUrls is what stops a Sentry project for a marketing site from filling up with errors from ad blockers, Chrome extensions, and injected shopping toolbars; without it, the signal-to-noise ratio on a public site is roughly 1:20. And ignoreErrors for Failed to fetch is not laziness — a user closing a tab mid-request produces it, and there is no bug to fix.

Catch the two Gatsby-specific errors properly

ChunkLoadError is the one you will see most. It means the browser is running an old app-*.js that is asking for a chunk hash that no longer exists on the CDN — a stale-cache-after-deploy problem. Reporting it is useful (a spike means your cache headers are wrong), but the user needs a reload, not a stack trace:

// gatsby-browser.js (continued)
window.addEventListener('error', (event) => {
  const msg = event?.message || '';
  if (!/ChunkLoadError|Loading chunk .* failed|Failed to fetch dynamically imported module/.test(msg)) return;

  Sentry.captureMessage('ChunkLoadError — reloading client', 'warning');

  // Reload once, and only once, so a genuinely broken deploy cannot loop.
  const KEY = 'chunk-reload-at';
  const last = Number(sessionStorage.getItem(KEY) || 0);
  if (Date.now() - last > 60_000) {
    sessionStorage.setItem(KEY, String(Date.now()));
    window.location.reload();
  }
});

The second is hydration failure. React 18/19 will silently re-render the whole tree client-side when server and client markup disagree, which tanks LCP and can blank out content. React does not throw for a mismatch, so you have to listen for the console warning in production builds:

// src/utils/hydration-watch.js
export const watchHydration = (Sentry) => {
  const original = console.error;
  console.error = (...args) => {
    const text = String(args[0] || '');
    if (/did not match|Hydration failed|hydrating/i.test(text)) {
      Sentry.captureMessage(`Hydration mismatch: ${text.slice(0, 200)}`, 'error');
    }
    original.apply(console, args);
  };
};

Patching console.error is a blunt instrument, so gate it behind a sample rate (5% of sessions is plenty — a real mismatch affects every visitor to that page) and keep the original call intact so local debugging is unaffected.

Wrap the page tree in an error boundary

An uncaught render error in a client-only route leaves a blank <div id="___gatsby">. Gatsby lets you wrap every page from gatsby-browser.js:

// src/components/error-boundary.js
import React from 'react';
import * as Sentry from '@sentry/browser';

export class ErrorBoundary extends React.Component {
  state = { failed: false };
  static getDerivedStateFromError() { return { failed: true }; }
  componentDidCatch(error, info) {
    Sentry.captureException(error, { extra: { componentStack: info.componentStack } });
  }
  render() {
    if (!this.state.failed) return this.props.children;
    return (
      <main style={{ padding: '4rem 1.5rem', maxWidth: 640 }}>
        <h1>Something went wrong on this page</h1>
        <p>Try reloading. If it keeps happening, <a href="/contact">let us know</a>.</p>
      </main>
    );
  }
}
// gatsby-browser.js
import React from 'react';
import { ErrorBoundary } from './src/components/error-boundary';

export const wrapPageElement = ({ element }) => <ErrorBoundary>{element}</ErrorBoundary>;

Mirror the same wrapPageElement in gatsby-ssr.js so the boundary exists in the static render too. Note the honest limitation: an error boundary catches render-phase errors, not events handlers' async rejections — for those you also want Sentry.init's global handlers, which are on by default.

2. Upload source maps, or the whole exercise is theatre

A minified stack trace reading t is not a function at a.js:1:84213 tells you nothing. Gatsby emits source maps into public/ by default, and — this is the part people miss — it deploys them. That means anyone can read your source, and you still get unreadable traces because the tool has no map for the release you tagged.

Fix both at once: upload the maps to your error tracker during CI, then delete them from public/ before deploy.

npm i -D @sentry/cli
# .github/workflows/deploy.yml (excerpt)
      - name: Build
        env:
          GATSBY_SENTRY_DSN: ${{ vars.GATSBY_SENTRY_DSN }}
          GATSBY_COMMIT_SHA: ${{ github.sha }}
        run: npx gatsby build

      - name: Upload source maps
        env:
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
          SENTRY_ORG: example-co
          SENTRY_PROJECT: marketing-site
        run: |
          npx sentry-cli releases new "$GITHUB_SHA"
          npx sentry-cli sourcemaps inject ./public
          npx sentry-cli sourcemaps upload --release "$GITHUB_SHA" ./public
          npx sentry-cli releases set-commits "$GITHUB_SHA" --auto
          npx sentry-cli releases finalize "$GITHUB_SHA"

      - name: Strip source maps from the deploy artifact
        run: find ./public -name '*.js.map' -delete

      - name: Deploy
        run: ./scripts/deploy.sh ./public

Three details that cause 90% of "my traces are still minified" tickets:

  • The release in Sentry.init and the --release you upload under must be byte-identical. Using github.sha for both is the simplest way to guarantee it.
  • Run sourcemaps inject before upload. It stamps debug IDs into the bundles so matching does not depend on fragile URL prefixes.
  • Delete the .map files after the upload and before the deploy — in that order, in separate steps. Squashing them into one command is how someone eventually ships the maps.

If you deploy from a host's own build step (Netlify, Cloudflare Pages, Vercel) rather than GitHub Actions, the same three commands go in your build command; the find ... -delete goes at the end of it.

3. Alert on the build, not just the code

This is the failure mode that costs real money on content sites, and almost nobody instruments it. Your Gatsby site rebuilds on a webhook or a cron; when the rebuild starts failing, production keeps serving the last good build and looks perfectly healthy.

Add an explicit "the build ran and produced something sane" check to gatsby-node.js, so a broken build fails loudly instead of shipping an empty site:

// gatsby-node.js
exports.onPostBuild = async ({ graphql, reporter }) => {
  const { data } = await graphql(`
    {
      allSitePage { totalCount }
      allMarkdownRemark { totalCount }
    }
  `);

  const pages = data.allSitePage.totalCount;
  const posts = data.allMarkdownRemark.totalCount;

  // Tune these to your site; the point is a floor, not an exact number.
  if (pages < 50) reporter.panicOnBuild(`Only ${pages} pages built — expected 50+. Refusing to deploy.`);
  if (posts < 20) reporter.panicOnBuild(`Only ${posts} posts sourced — check the CMS token.`);

  reporter.info(`Build sanity: ${pages} pages, ${posts} posts`);
};

panicOnBuild sets a non-zero exit code, which is what turns a silent content-source outage into a red CI run. A CMS token that expired mid-quarter produces a successful Gatsby build with three pages in it — this check is the only thing standing between that and your entire blog 404ing.

Then make the failure reach a human. In GitHub Actions:

      - name: Notify on failure
        if: failure()
        run: |
          curl -sf -X POST "$SLACK_WEBHOOK" \
            -H 'content-type: application/json' \
            -d "{\"text\":\"Gatsby build failed on ${GITHUB_REF_NAME} — ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}\"}"
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}

And add a staleness check, which catches the webhook that stopped firing. Write the build time into the output, then assert on it from outside:

// gatsby-node.js (continued)
const fs = require('node:fs/promises');
exports.onPostBuild = async () => {
  await fs.writeFile('public/build-info.json', JSON.stringify({
    builtAt: new Date().toISOString(),
    commit: process.env.GATSBY_COMMIT_SHA || 'unknown',
  }));
};
#!/usr/bin/env bash
# scripts/check-freshness.sh — run from cron or a scheduled workflow
set -euo pipefail
BUILT=$(curl -sf https://www.example.com/build-info.json | jq -r .builtAt)
AGE_DAYS=$(( ( $(date +%s) - $(date -d "$BUILT" +%s) ) / 86400 ))
echo "Last build: $BUILT (${AGE_DAYS}d ago)"
[ "$AGE_DAYS" -lt 7 ] || { echo "::error::Production build is ${AGE_DAYS} days old"; exit 1; }

Seven days is a reasonable threshold for a site that rebuilds on content changes; tighten it to one day if you source from an API that changes daily.

4. Check production itself, from outside

The last layer is synthetic monitoring: does the real URL, over the real CDN, still return the real page? Uptime services (Better Stack, Uptime Robot, Cronitor, or a scheduled workflow) all work. What matters is what you assert — a 200 on / catches almost nothing, because a CDN happily serves a 200 for a stale or half-deployed site.

Assert on content and on the things that silently disappear:

#!/usr/bin/env bash
# scripts/smoke-prod.sh
set -euo pipefail
BASE=${1:-https://www.example.com}
fail=0
check() { if eval "$2"; then echo "ok   $1"; else echo "FAIL $1"; fail=1; fi; }

check "home renders headline in HTML" \
  "curl -sf $BASE/ | grep -q 'Static site consulting'"
check "a deep page still exists" \
  "curl -sf $BASE/tutorials/ | grep -q '<article'"
check "404 page returns 404" \
  "[ \$(curl -s -o /dev/null -w '%{http_code}' $BASE/definitely-not-a-page) = 404 ]"
check "sitemap present" \
  "curl -sf $BASE/sitemap-index.xml -o /dev/null"
check "security headers present" \
  "curl -sfI $BASE/ | grep -qi 'strict-transport-security'"
check "legacy redirect still works" \
  "[ \$(curl -s -o /dev/null -w '%{http_code}' $BASE/old-services-page) = 301 ]"
check "source maps NOT deployed" \
  "[ \$(curl -s -o /dev/null -w '%{http_code}' $BASE/app-*.js.map) != 200 ]"
check "contact form endpoint alive" \
  "[ \$(curl -s -o /dev/null -w '%{http_code}' -X POST $BASE/api/contact -d '{}' -H 'content-type: application/json') != 500 ]"

exit $fail

Run it on a schedule against production, and once against the preview URL in every pull request. The redirect assertion is the sleeper: redirect maps live in host config that is easy to drop during a platform change, and a lost 301 quietly removes a page's accumulated link equity with no error anywhere.

For form endpoints, check that the endpoint responds, not that it accepts a submission — repeatedly posting real leads into your own CRM from a monitor is its own kind of outage.

5. Decide what wakes someone up

Instrumentation without an alerting policy just relocates the problem. A workable default for a static marketing or content site, where nothing is on-call:

SignalRoute toUrgency
Uptime check fails twice in a rowSlack + emailImmediate
Build failed on mainSlackImmediate
New unhandled exception type, >20 sessions/hourSlackSame day
ChunkLoadError spike after a deploySlackSame day — check cache headers
Production build older than 7 daysSlackWeekly review
Individual JS errors, low volumeDashboard onlyNever alerted

The last row is the important one. If every one-off browser-extension error generates a notification, the channel gets muted within a fortnight and the real outage arrives in a muted channel. Set thresholds, review the dashboard weekly, and prune ignoreErrors as the noise reveals itself.

Cost and privacy notes

Error trackers bill on event volume, and a public site with no allowUrls filter can burn a month's quota in a day. Start with tracesSampleRate: 0.1, keep the filters tight, and set a spend cap on day one.

On privacy: error payloads include URLs, and URLs on gated or search pages can contain personal data. Scrub them before they leave the browser, and do not enable session replay on any page behind a login without checking your privacy notice and consent posture first:

  beforeSend(event) {
    if (event.request?.url) {
      event.request.url = event.request.url.split('?')[0];
    }
    delete event.user?.ip_address;
    return event;
  },

The short version

Build a clean Gatsby site and you have removed a whole class of runtime risk — you have not removed observability as a requirement. Four things, in this order of value: sanity-check the build and make its failure loud; upload source maps and strip them from the deploy; assert on real production content, redirects, and headers from outside; and track client errors with filters tight enough that the alerts still mean something in six months.

If you would like help instrumenting an existing Gatsby site, or a review of a build pipeline that has been quietly failing, get in touch — a monitoring audit is usually a day or two of work and it tends to surface a few things nobody knew were broken.