Live "online users" on a static blog: GA4 Realtime + a Cloudflare Worker
I wanted the homepage stats bar to show a current online users number, next to the build-time stats (posts, tags, days since last post). It sounds like a one-liner, but a static site fundamentally cannot call the GA4 Realtime API directly — the API needs a service-account private key, and anything you ship to the browser is public. This post is the whole path I took: the build-time-vs-runtime split, why a proxy is mandatory, the Cloudflare Worker that signs its own Google token, wiring the frontend, and the three traps I fell into along the way.
The realization: build-time vs runtime
The stats bar already had four numbers, and they were all computed at build time by reading the repo:
posts/tags— counted from blog frontmatter while Docusaurus builds.last post— diff between today and the newest post date.
Those are static: bake them into the HTML and they're correct until the next build. A visitor count is the opposite — it only exists at runtime, on a server that's recording traffic. The site itself records nothing. So any "visitors" or "online" number needs an external analytics source queried from the browser. That single distinction drove the whole design.
Why you can't call GA Realtime from the browser
GA4's realtime data comes from the Analytics Data API (properties/{id}:runRealtimeReport,
metric activeUsers). To call it you must authenticate as a service account — which
means holding its private key and signing a JWT to exchange for an access token.
A static site's JavaScript is fully visible to anyone. Put that key in the bundle and you've handed out read access to your analytics. So the only safe shape is a tiny server in the middle:
browser (stats bar) ──► Cloudflare Worker ──► GA4 Realtime API
(holds the key,
caches ~60s,
returns { "active": N })
The Worker is the only thing that ever sees the key. The browser only ever talks to the
Worker, which returns a harmless { "active": N } with permissive CORS.
GA's "active users" means users active in the last 30 minutes — the exact number GA's Realtime report shows. It's the industry-standard "online now," not a literal "looking at the page this very second."
Step 1 — page tracking (the easy, SEO part)
This is the part most people mean by "add Google Analytics," and it's trivial. Docusaurus' classic preset has a built-in gtag plugin that also handles client-side route changes:
// docusaurus.config.ts
const GA_MEASUREMENT_ID = 'G-XXXXXXXXXX';
presets: [
['classic', {
// ...
...(GA_MEASUREMENT_ID
? {gtag: {trackingID: GA_MEASUREMENT_ID, anonymizeIP: true}}
: {}),
}],
],
This alone gives you SEO/traffic analytics. It does not give you the realtime number — that's Steps 2–4.
GA4 shows you three different IDs and it's easy to grab the wrong one:
- Measurement ID (
G-XXXXXXXXXX) — for gtag page tracking (this step). - Stream ID (a long number on the data-stream page) — not what the API wants.
- Property ID (a number under Admin → Property settings) — this is what the Realtime API call below needs.
Step 2 — service account + Data API
- In Google Cloud Console, enable the Google Analytics Data API.
- Create a service account, then create and download a JSON key.
- In GA Admin → Property access management, add the service account's
client_emailas a Viewer on the property. (Skipping this is Pitfall 3 below.)
Step 3 — the Worker: signing a Google JWT with WebCrypto
Cloudflare Workers have no googleapis SDK, but they do have WebCrypto, so you build and
sign the JWT yourself. The flow: make a JWT claiming the service-account identity, sign it
RS256 with the private key, exchange it for an access token, then call the Realtime report.
The interesting bits:
async function getAccessToken(env) {
const now = Math.floor(Date.now() / 1000);
const header = {alg: 'RS256', typ: 'JWT'};
const claim = {
iss: env.GA_CLIENT_EMAIL,
scope: 'https://www.googleapis.com/auth/analytics.readonly',
aud: 'https://oauth2.googleapis.com/token',
iat: now, exp: now + 3600,
};
const unsigned = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(claim))}`;
const key = await importPrivateKey(env.GA_PRIVATE_KEY); // PKCS8 PEM -> CryptoKey
const sig = await crypto.subtle.sign(
{name: 'RSASSA-PKCS1-v1_5'}, key,
new TextEncoder().encode(unsigned),
);
const jwt = `${unsigned}.${b64urlBytes(new Uint8Array(sig))}`;
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: {'content-type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: jwt,
}),
});
return (await res.json()).access_token;
}
Two details that bite:
- The private key from the JSON is PKCS8 PEM. Strip the
-----BEGIN/END-----lines and whitespace, base64-decode to bytes, thencrypto.subtle.importKey('pkcs8', ...). Also normalize\n→ real newlines, because secrets are often stored with escaped newlines. - Cache both the access token (~55 min) and the active-users result (~60 s) in module scope. Otherwise every page load hits Google and you'll burn through quota.
The actual report call is small:
const res = await fetch(
`https://analyticsdata.googleapis.com/v1beta/properties/${env.GA_PROPERTY_ID}:runRealtimeReport`,
{method: 'POST',
headers: {authorization: `Bearer ${token}`, 'content-type': 'application/json'},
body: JSON.stringify({metrics: [{name: 'activeUsers'}]})},
);
const data = await res.json();
const active = Number(data?.rows?.[0]?.metricValues?.[0]?.value ?? 0);
Secrets are set with Wrangler (note the names must match env.X in the code):
npx wrangler secret put GA_CLIENT_EMAIL # paste the client_email
npx wrangler secret put GA_PRIVATE_KEY # paste the private_key value
npx wrangler deploy
GA_PROPERTY_ID and ALLOWED_ORIGIN are non-secret, so they live in wrangler.toml.
Step 4 — wire the frontend (poll every 60s)
The homepage reads the Worker URL from config and polls it after hydration. During the static build there's no fetch, so it renders a placeholder, then fills in on the client:
function useOnline(apiUrl: string): string {
const [value, setValue] = useState('···');
useEffect(() => {
if (!apiUrl) { setValue('—'); return; }
let alive = true;
const load = () =>
fetch(apiUrl)
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d) => alive && setValue(String(d.active ?? '—')))
.catch(() => alive && setValue('—'));
load();
const id = setInterval(load, 60_000);
return () => { alive = false; clearInterval(id); };
}, [apiUrl]);
return value;
}
A failed/unconfigured fetch falls back to —, so the page never shows a broken stat.
Step 5 — serve it from your own domain (optional, but cleaner)
The Worker deploys to a *.workers.dev URL by default. If your domain's DNS is already
managed by Cloudflare, you can serve it from your own subdomain instead — nicer to read,
not tied to the workers.dev subdomain, and less likely to be tripped up by privacy
extensions or networks that block *.workers.dev.
Add a custom-domain route to wrangler.toml and redeploy:
[[routes]]
pattern = "ga4-proxy.forklore.dev"
custom_domain = true
npx wrangler deploy
wrangler deploy provisions the DNS record and the TLS certificate automatically (the
cert takes a minute or two). Then point the frontend at the new URL:
const ONLINE_API_URL = 'https://ga4-proxy.forklore.dev';
curl https://ga4-proxy.forklore.dev # {"active":0}
ALLOWED_ORIGIN does not change — it stays https://forklore.dev. The Worker's own
hostname (where it's served) and the caller's origin (who's allowed to call it, the CORS
setting) are two different things. A custom domain only needs the zone to be on
Cloudflare; it works even if the blog itself is hosted elsewhere.
Folding in the visitor count (and dropping GoatCounter)
The realtime "online" number is only half the stats bar — I also wanted an all-time
unique visitors count. My first version used GoatCounter:
cookieless, zero-config, a one-line script plus a public /counter/TOTAL.json endpoint the
page could fetch directly — no proxy needed. It worked.
Then I deleted it. The reasoning:
- GA was already in the page for SEO/analytics, and the Worker already existed for the realtime number. GoatCounter's headline advantage is being cookieless / privacy-first — but that edge evaporates the moment you're already running GA. I wasn't actually buying the one thing GoatCounter is best at.
- Keeping it meant two analytics sources to reconcile and a second third-party script on every page, for a number GA can already give me.
So I consolidated onto the Worker. GA's all-time count comes from a standard report (not realtime), so the Worker makes a second call and returns both numbers:
// alongside the realtime activeUsers call, a standard report for all-time users:
const data = await gaReport(env, token, 'runReport', {
dateRanges: [{startDate: '2020-01-01', endDate: 'today'}],
metrics: [{name: 'totalUsers'}],
});
const total = Number(data?.rows?.[0]?.metricValues?.[0]?.value ?? 0);
// the Worker response becomes: { active, total }
The frontend now reads active (online) and total (visitors) from a single fetch, and the
GoatCounter script and account are gone.
- Concept: don't run two tools for one job unless each earns its place. GoatCounter would have earned it if I weren't already on GA — cookieless analytics is a real, distinct value. Here it was redundant, and a redundant dependency is pure maintenance cost. The cheapest integration is the one you don't keep.
The pitfalls (the part that actually cost time)
Pitfall 1: npm audit fix --force wrecked the toolchain
npm install for Wrangler succeeded, but it reported a few "vulnerabilities," so I reflexively
ran npm audit fix --force. That made a major-version change and ended up downgrading
Wrangler to a very old release, which dragged in an old better-sqlite3 that has no
prebuilt binary for Node 24 — so it tried to compile from C++ source and exploded with
C++20 or later required.
- Cause:
--forceis allowed to make breaking version changes to silence advisories. - Fix:
rm -rf node_modules package-lock.json, pin a currentwrangler(^4), reinstall, and don't runaudit fixhere. - Concept: an
npm auditadvisory describes a risk for some usage of a package. The ones here were in esbuild's dev server — code that never ships to the deployed Worker. The advisory being real doesn't mean my path is exposed.--force"fixing" a non-applicable advisory did far more damage than the advisory ever could.
Pitfall 2: wrangler secret put takes the NAME, not the value
I ran wrangler secret put [email protected], pasted a value at the
prompt, and "succeeded" — having created a secret whose name was the email.
- Cause: the argument to
secret putis the secret's key; the value is read from the interactive prompt afterward. - Fix:
wrangler secret delete "<the wrong name>", thenwrangler secret put GA_CLIENT_EMAILand paste the email at the prompt. - Concept: the secret name must exactly match what the code reads (
env.GA_CLIENT_EMAIL). A secret with the right value but the wrong name is invisible to the Worker.
Pitfall 3: GA 403 PERMISSION_DENIED
First real request to the Worker returned:
{"active": null,
"error": "GA API 403: ...User does not have sufficient permissions for this property..."}
- Cause: the JWT, token exchange, and the API call all worked — the service account simply hadn't been granted access to the GA property yet.
- Fix: GA Admin → Property access management → add the service account's
client_emailas Viewer, wait a minute, retry. - Concept: a 403 here is reassuring — it means auth succeeded and you reached the
property; it's purely an authorization (access-grant) gap, not a broken key. Read the error
status:
PERMISSION_DENIED(no access) is a different problem fromUNAUTHENTICATED(bad token) orSERVICE_DISABLED(API not enabled).
Verify
Curl the Worker (or just open it). Once the service account has access you should get:
curl https://<your-worker>.workers.dev
# {"active":0,"total":0}
0 is correct when nobody's currently on the site — null would mean something upstream is
still wrong. A real number proves the whole chain.
Summary
| Piece | When it runs | Where it's configured | Secret? |
|---|---|---|---|
| posts / tags / last post | build time | computed from blog frontmatter | no |
| page tracking (gtag) | runtime | gtag preset option (Measurement ID) | no |
| online + visitors | runtime | Worker (runRealtimeReport + runReport) | — |
| Property ID / Measurement ID | — | wrangler.toml / docusaurus.config.ts | no |
| service-account key | runtime | Cloudflare secret (GA_PRIVATE_KEY) | yes |
The mental model worth keeping: static numbers get baked at build; live numbers need a runtime source, and any source that requires a private key needs a proxy in front of it. The proxy is small, but it's the whole reason the secret never leaks.
