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#

  1. Write a script that queries the data source with a read-scoped key. scripts/fetch-traffic-dashboard.mjs runs 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 key tracking-posthog wired up, which is write-only by design and safe to expose.
  2. 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.
  3. Schedule the pull and commit the result as one small data file. .github/workflows/traffic-dashboard.yml runs the script daily (schedule: cron) plus on demand (workflow_dispatch), and commits src/data/traffic-dashboard.json directly to main if 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.
  4. Read that file at build time, not the browser. TrafficDashboard.astro imports 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.
  5. 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: true until 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.

Nathaniel Stich writes Exit Velocities — recipes drawn from AI/growth systems shipped in production, not opinion pieces.

About the author