Analytics is where most otherwise-fast Gatsby sites fall apart. The build ships a lean, pre-rendered HTML document, and then a tag manager container, a session-recording script, and two ad pixels arrive on the main thread and undo the work. Meanwhile the legal side is stricter than it was: under the EU rules that took effect for Google advertisers, tags that feed Google Ads or GA4 audiences must send consent signals, and a banner that fires the tag anyway while showing a cookie notice is worse than no banner at all.
This tutorial is the setup we put on client Gatsby 5 sites: measure what matters, keep the main thread free, and make the consent gate real rather than decorative.
1. Decide what you actually need to measure
For a services site the useful questions are short: which pages bring in enquiries, which sources those enquiries come from, and whether the site is fast enough for people to stay. That list does not require session recording, heatmaps, or four ad platform pixels loaded on every page.
Before adding anything, write down the events you need. On our own site that is three:
page_view— path and referrercontact_submit— form submitted successfullyoutbound_click— clicks to email or phone links
Everything else can be added later when someone asks a question the data cannot answer. Every script you skip is a chunk of main-thread time you keep.
2. Load third-party scripts with gatsby-script, not a raw tag
Gatsby 5 ships a Script component with loading strategies. The default post-hydrate runs after React hydrates; idle waits for the browser to be idle; off-main-thread runs the script in a web worker via Partytown.
// src/components/analytics.js
import React from 'react';
import { Script } from 'gatsby';
export default function Analytics() {
return (
<Script
src="https://plausible.io/js/script.outbound-links.js"
strategy="idle"
data-domain="example.com"
/>
);
}
The rule of thumb: anything that must observe the first paint (a consent banner, a Core Web Vitals collector) loads post-hydrate; anything analytical loads idle; heavy tag managers go off-main-thread if they work there at all.
Partytown is not free lunch. It proxies the script's network calls through a service worker or reverse proxy, and tags that read cookies synchronously or write to the DOM can misbehave. Test the events land before you assume off-main-thread solved your INP problem — and check the gatsby-script docs for the forwarding config Partytown needs, e.g. dataLayer.push.
3. Prefer a cookieless tool if you can
If the analytics tool sets no cookies and stores no cross-site identifier, most jurisdictions do not require a consent banner for it at all. That removes the banner, the consent state machine, and the argument with legal in one move. Plausible, Fathom, Umami (self-hosted), and Cloudflare Web Analytics all fit. GoatCounter and a self-hosted Umami on a small VPS cost effectively nothing.
The cost is real: no cross-device user journeys, no Google Ads conversion import unless you build it, coarser attribution. For a consulting site whose funnel is "landed on a page, read it, emailed us", that trade is usually right. For a client running paid acquisition at volume, it is not — they need GA4 and Google Ads, which means the consent work in the next two sections.
4. Build a consent gate that withholds the request
The failure we see most often: a banner component renders, the user has not clicked anything, and the GA4 script is already in the DOM because it was hard-coded in gatsby-ssr.js or added by gatsby-plugin-google-gtag. The banner is decoration.
Make the tag's existence conditional on state, and persist that state in localStorage rather than a cookie:
// src/components/consent-provider.js
import React, { createContext, useContext, useEffect, useState } from 'react';
const KEY = 'consent.v1';
const ConsentContext = createContext({ consent: null, setConsent: () => {} });
export function ConsentProvider({ children }) {
const [consent, setState] = useState(null);
useEffect(() => {
try {
const raw = window.localStorage.getItem(KEY);
if (raw) setState(JSON.parse(raw));
} catch (e) {
/* storage blocked; stay unset, which means denied */
}
}, []);
const setConsent = (value) => {
setState(value);
try {
window.localStorage.setItem(KEY, JSON.stringify(value));
} catch (e) {}
};
return (
<ConsentContext.Provider value={{ consent, setConsent }}>
{children}
</ConsentContext.Provider>
);
}
export const useConsent = () => useContext(ConsentContext);
Wrap the app in gatsby-browser.js and gatsby-ssr.js with wrapRootElement so the provider exists in both renders, then render the tag only when granted:
const { consent } = useConsent();
if (consent?.analytics !== true) return null;
return <Script src="https://www.googletagmanager.com/gtag/js?id=G-XXXX" strategy="idle" />;
Two Gatsby-specific traps. First, the banner is client-only state, so do not render its "accepted" and "not accepted" variants differently during SSR — React will hydrate against HTML that says something else and you get a mismatch. Render nothing on the server, then the banner after mount. Second, null and false are different: an unset consent value must behave as denied, not as "ask again later while the tag runs".
5. Consent Mode v2 defaults, set before the tag loads
If you keep GA4 or Google Ads, Google expects consent signals via Consent Mode v2, including the ad_user_data and ad_personalization parameters. The defaults must be in the page before gtag.js loads, which on Gatsby means an inline script rendered by gatsby-ssr.js so it lands in the static HTML:
// gatsby-ssr.js
import React from 'react';
export const onRenderBody = ({ setPreBodyComponents }) => {
setPreBodyComponents([
<script
key="consent-default"
dangerouslySetInnerHTML={{
__html: `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}
gtag('consent','default',{ad_storage:'denied',ad_user_data:'denied',ad_personalization:'denied',analytics_storage:'denied',wait_for_update:500});`,
}}
/>,
]);
};
Then, when the visitor accepts, update:
window.gtag?.('consent', 'update', {
analytics_storage: 'granted',
ad_storage: 'granted',
ad_user_data: 'granted',
ad_personalization: 'granted',
});
Note that inline script and a strict Content-Security-Policy do not mix: you need a per-build nonce or a hash in your script-src. If you have already shipped a CSP, add the hash to the header at build time rather than falling back to unsafe-inline.
6. Attribute leads with first-party data
The number the business cares about is which page produced the enquiry. You can get that without any third-party identifier.
Capture the landing path, referrer, and campaign parameters into sessionStorage on first page load, then submit them as hidden fields with the contact form:
// src/utils/attribution.js
export function captureAttribution() {
if (typeof window === 'undefined') return;
if (window.sessionStorage.getItem('attr')) return;
const params = new URLSearchParams(window.location.search);
window.sessionStorage.setItem('attr', JSON.stringify({
landing: window.location.pathname,
referrer: document.referrer ? new URL(document.referrer).hostname : '',
utm_source: params.get('utm_source') || '',
utm_campaign: params.get('utm_campaign') || '',
ts: Date.now(),
}));
}
Call it from onClientEntry in gatsby-browser.js. The form handler — a serverless function, a form backend, or your CRM endpoint — stores those fields alongside the message. Now "the migration services page produced four enquiries last quarter" is a query against your own data, not a report you have to trust.
Keep the payload minimal and say so in the privacy policy. Storing a landing path and a referrer hostname on your own origin is a very different proposition from a cross-site profile, and it survives ad blockers, ITP, and consent refusals — which is exactly why it is the most reliable number you will have.
7. Verify, then keep verifying
After deploy, run the checks that catch the usual regressions:
- Load the site in a fresh private window with the network tab open and do not touch the banner. No requests to analytics or ad domains should appear.
- Accept, reload, confirm the tag fires and the consent update is in the dataLayer.
- Run Lighthouse before and after the tag loads; compare total blocking time.
- Add a Playwright test asserting no request matches your analytics hosts before consent. Consent regressions come back the next time someone adds a pixel "just for a campaign".
A Gatsby site earns its performance at build time. Measurement should be the one part of the stack that does not spend it.
Need help untangling tags, consent, and Core Web Vitals on a Gatsby site? Get in touch — our consultants do this as a fixed-scope engagement.