Growth & Marketing AI
Building a Traffic Dashboard on a Site With No Server
Result:
Your static site gets a dashboard that looks live — real numbers, refreshed on a schedule — without a server, a database, or a real API key ever reaching a visitor's browser. Same pattern works for any read API: search console coverage, MRR, star counts, not just traffic.
Part of a series: this recipe reads the traffic data that tracking who’s visiting your site starts capturing. Run that one first if your site isn’t sending events yet.
Shortcut
Paste this into Claude Code, pointed at your own static site’s repo, once the key below is saved to GitHub Secrets.
Requires manual input
A PostHog Personal API Key from your own account (Settings, then Personal API Keys), pasted into your repo’s GitHub Secrets by hand. An agent can build the pipeline around it, not the credential itself.
Build a repo-route traffic dashboard on my static site, reading from
PostHog:
1. Write a script (scripts/fetch-traffic-dashboard.mjs) that queries
PostHog's HogQL Query API for daily, weekly, and monthly
sessions/users/events, using a Personal API Key read from an
environment variable, and writes the result to
src/data/traffic-dashboard.json.
2. Write a GitHub Actions workflow that runs that script daily on a cron
schedule and on demand (workflow_dispatch), reads the API key and
project id from GitHub Secrets, and commits the JSON file back to main
if it changed.
3. Build a component that reads that JSON file at build time and renders
a small stat row plus three individual, labelled line charts (sessions,
users, events), all visible at once, sharing one daily/weekly/monthly
toggle and one per-period/cumulative view toggle above them, showing an
honest empty state if the file hasn't been populated yet.
4. Use UTC consistently for every date bucket and label, so the chart
doesn't drift a day off for readers west of UTC.Problem#
Wiring up PostHog answered where traffic comes from; it didn’t answer how to show it anywhere. This site is fully static — Astro output, Cloudflare Workers serving built assets, no server, no database, no per-request code running at all. A “live” dashboard in that world can’t mean a browser calling PostHog’s API directly: that would mean shipping a read-scoped API key to every visitor’s browser, and a client-side dashboard that recomputes sessions/users/events on every single pageview for no reason. Something has to sit between the real data source and the page. The shape is general — connect the data to a scheduled skill, connect that skill to a repo — and it applies here to traffic; a search-visibility dashboard (Search Console coverage, indexing, rank) would follow the same shape but isn’t built yet.
Pattern#
- Write a script that queries the data source with a read-scoped key.
scripts/fetch-traffic-dashboard.mjsruns three HogQL queries against PostHog’s Query API (daily/weekly/monthly session, user, and event counts) using a Personal API Key — a different, read-only credential from the client-side capture keytracking-posthogwired up, which is write-only by design and safe to expose. - Keep that key out of the browser and out of the repo. It lives in GitHub Secrets (e.g.
POSTHOG_API_KEY,POSTHOG_PROJECT_ID), injected only as an environment variable inside a GitHub Actions job — never in.env, never in a client bundle. - Schedule the pull and commit the result as one small data file.
.github/workflows/traffic-dashboard.ymlruns the script daily (schedule: cron) plus on demand (workflow_dispatch), and commitssrc/data/traffic-dashboard.jsondirectly tomainif it changed. That’s a deliberate, narrow exception to this repo’s “never push directly to main” convention — scoped to one generated data file, never source or config. A dashboard that needed a human to review and merge a PR every day wouldn’t be live. - Read that file at build time, not the browser.
TrafficDashboard.astroimports the JSON like any other data and renders it server-side — three individual charts (sessions, users, events) render simultaneously, and the daily/weekly/monthly toggle plus a per-period/cumulative view toggle both apply to all three at once, swapping between datasets already baked into the page (up to eighteen of them: three periods times three metrics times two views), so switching either toggle costs zero network calls. Cumulative isn’t a separate query — it’s a running sum computed at build time from the same per-bucket series. - Show an honest empty state before there’s real data. The component checks whether the data
file has been populated and renders a plain “no data yet” panel instead of inventing
numbers — the same discipline as the SEO Dashboard recipe staying
draft: trueuntil real coverage data existed.
Output#
Build this and your dashboard page loads instantly and works with JavaScript disabled. The
tradeoff is honest, not hidden: numbers are as fresh as the last scheduled pull, not real-time.
That’s proven against the real project here, not a mock — see
this site’s own /dashboard for the live version, reading real PostHog data through
the exact pipeline this recipe describes. The first test dispatch of the workflow failed in 18
seconds — both env vars came through empty, because the variable names the workflow expected
didn’t match the names the secrets were saved under in the repo settings, a classic
naming-mismatch bug you only catch by running the thing. Fixed the mapping, re-ran it, and
it succeeded:
✓ refresh in 23s
✓ Set up job
✓ Run actions/checkout@v4
✓ Run actions/setup-node@v4
✓ Run npm ci
✓ Fetch PostHog traffic data
✓ Commit updated data if it changed
The bot’s commit landed real numbers: 12 sessions, 8 users, 166 events — small, because the
PostHog project itself was only created a few hours before this recipe was written (see
tracking-posthog’s own “Output” for that timeline), so daily/weekly/monthly all collapse to
a single bucket for now. That single real data point caught a genuine bug before it shipped: the
chart’s date labels were rendering in the viewer’s local timezone instead of UTC, so a bucket
boundary PostHog reported as 2026-07-20T00:00:00Z displayed as “Jul 19” for anyone west of UTC.
Fixed by forcing timeZone: 'UTC' in the label formatting, matching the UTC bucket boundaries
toStartOfDay/toStartOfWeek/toStartOfMonth use.
Update (2026-07-24): the chart originally plotted sessions only, with users/events as KPI
numbers but no trend line. fetch-traffic-dashboard.mjs now pulls a per-bucket array for all three
metrics, and the component renders all three as individual, labelled charts stacked together
(tried a toggle that switched between them first, but showing all three side by side reads better
than making a reader click through them one at a time) plus a shared toggle for whether every chart
plots its raw per-period series or a running cumulative total. A committed data file from before
this change only has the sessions array, so the component checks for all three before rendering the
extra two charts and quietly falls back to the single old sessions-only chart otherwise — it
upgrades itself the next time the scheduled pull writes the new shape, no manual migration step.
The cumulative toggle has no such dependency, since it’s just a running sum of whatever series is
already loaded, not a new query.
Variations#
- Pull cadence — daily is the current schedule; an hourly cron is one YAML line
(
cron: '17 * * * *') if the dashboard needs to feel more current, at the cost of more Action minutes. - This generalizes past PostHog — the shape (a scheduled script, a read-scoped secret in GitHub Secrets, one committed data file, a static page that reads it) works for any data source with a read API: Search Console’s coverage stats, a Stripe MRR number, GitHub’s own star count. Search Console coverage is the next place to apply it, once that recipe gets written.
- Why commit the data instead of fetching it at Cloudflare build time — Cloudflare Workers
Builds already run on every push (see
tracking-posthog’s BUILDLOG entry on branch-push behavior); pulling fresh data on every one of those builds would mean a real API call on every commit, not just once a day, and would need the read-scoped key present in Cloudflare’s build variables too. One scheduled pull, one committed file, is simpler and puts the data in git history where it’s inspectable.