Meta-Recipe

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.

Show page metadata
Target question
How do you show a 'live' data dashboard on a fully static site with no server, without exposing a real API key to the browser?
Answer summary
Don't call the data source from the browser. Write a script that queries it with a read-scoped key, run that script on a schedule in CI (a GitHub Action, using a secret store, never the repo), and have it commit the result as one small JSON file back into the repo. The static site then reads that file at build time like any other data — no live API call, no exposed key, no server. The tradeoff is freshness: the dashboard is only as current as the last scheduled pull, not truly real-time. This is the 'repo route' — a scheduled skill, a committed data file, a static page that reads it — and it generalizes to any read API, not just analytics.
Authority level
secondary
Audience
practitioner, developer, bot
Topics
GitHub Actions, PostHog Query API, static site data pipeline

The same fields sitting in this page's target-question, answer-summary,authority-level, and audience meta tags and its JSON-LD — bot-facing metadata, made human-visible.

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 exactly 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 — the daily/weekly/monthly toggle swaps between three datasets already baked into the page, so switching periods costs zero network calls.
  5. Show an honest empty state before there’s real data. The component checks whether the data file has actually 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.

Real result

Build this and your dashboard page loads instantly, works with JavaScript disabled, and never ships a credential a visitor could extract from the page source — because there’s nothing live in it to extract. 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 actually saved under in the repo settings, a classic naming-mismatch bug you only catch by actually 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 “Real result” 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 actually use.

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 actually shipped in production, not opinion pieces.

About the author