Can a static site show page views without a server?

How to display page views on a static site by exporting GA4, Cloudflare, Plausible, or Umami aggregates to JSON with GitHub Actions instead of building a counter backend.

A static site can show page views without running an application server. The mistake is trying to increment a public counter on every request. That immediately creates a writable datastore, duplicate-visit rules, bot filtering, and abuse prevention.

For a small blog, change the direction of the data flow: let an analytics service collect traffic, export aggregate values to static JSON, and make the site read that file.

This can support “popular in the last seven days,” approximate per-post views, or a simple traffic trend. It gives up real-time updates and exact person-level counting. That trade-off is what keeps the feature compatible with static hosting.

Decide what the number means

Define the metric before choosing where it appears.

Desired feature Possible without a server? Practical approach
Previous-day post views Yes Export analytics aggregates to JSON
Popular posts over 7 days Yes Refresh once per day
Real-time active visitors Poor fit Keep it in the analytics dashboard
Increment on every click Poor fit Needs writes and abuse protection
Likes Risky Needs deduplication, undo, and moderation

For a small publication, a rough popularity signal is usually more useful than a supposedly exact counter. “128 views in the last seven days” communicates the collection window and avoids pretending that every bot, blocked script, and returning visitor was counted perfectly.

Choose a source you can keep operating

The useful question is not which analytics product has the most features. Ask whether it can return stable aggregates by article URL and whether you can explain the displayed metric.

Source Good fit when Export path Main trade-off
GA4 Data API GA4 is already installed GitHub Actions writes JSON Property and service-account setup
Cloudflare Analytics + GraphQL Traffic already passes Cloudflare Query aggregates, then write JSON Counts Cloudflare-visible traffic
Plausible Stats API A paid, simple analytics tool is fine Query pageviews and visitors Check plan and API limits
Umami You want to own the analytics stack Use stats and pageview endpoints Self-hosting adds operations
GoatCounter Lightweight public stats are enough API or export Best for simpler aggregates
Simple Analytics Privacy-oriented hosted analytics Stats or export API Check authentication and publication

Reuse existing data first. Adding a second tracking script only to show a small number often creates more maintenance than the feature is worth.

Use three tests:

1. Is the data already being collected?
2. Can it be aggregated reliably by article URL?
3. Does the meaning survive when published as a daily JSON snapshot?

If any answer is no, hiding the number may be the more honest product decision.

A serverless publishing flow

The browser should not perform authenticated writes or call a private analytics API.

Visitor browser
  -> analytics service collects traffic
  -> GitHub Actions calls the API once per day
  -> workflow writes public/stats/posts.json
  -> static pages read the deployed JSON

The Google Analytics Data API supports programmatic reports, including top pages and page-view metrics over a date range. Cloudflare’s GraphQL Analytics API exposes aggregated traffic data for requests visible to Cloudflare. Plausible, Umami, GoatCounter, and Simple Analytics provide different combinations of metrics and export endpoints.

Availability is not the only criterion. Authentication, pricing, retention, and the rules for exposing statistics publicly determine whether the integration will remain manageable.

Keep JSON aggregated

Do not publish raw visit events:

[
  { "path": "/en/posts/a", "visitedAt": "2026-07-02T09:01:00Z" },
  { "path": "/en/posts/a", "visitedAt": "2026-07-02T09:02:00Z" }
]

That file grows forever and should not live in a public static repository. Publish a bounded aggregate instead:

{
  "generatedAt": "2026-07-02T00:00:00Z",
  "window": "last_7_days",
  "posts": {
    "/en/posts/github-pages-seo-tradeoffs/": {
      "views": 128,
      "users": 74
    },
    "/en/posts/why-not-nextjs-static-blog/": {
      "views": 92,
      "users": 51
    }
  }
}

Even 100 articles produce a small file when each entry contains only a few numbers. Rebuild the snapshot instead of appending events. If you need trends, keep a rolling 30-day series or separate monthly summaries.

Data shape Growth rate Static-site fit
Raw visit events Fast Poor
Lifetime views per post Slow Good
Last-seven-day views Nearly fixed Good
Thirty daily aggregates Bounded Reasonable
Per-user history Risky Poor

Refresh with GitHub Actions

A scheduled workflow keeps the write path outside the website:

name: update-post-stats

on:
  schedule:
    - cron: "15 0 * * *"
  workflow_dispatch:

jobs:
  update:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: node scripts/update-post-stats.mjs
      - run: git add public/stats/posts.json
      - run: git diff --cached --quiet || git commit -m "data: update post stats"

The real workflow needs credentials. A GA4 export needs a property ID and suitable service-account access. A Cloudflare query needs a narrowly scoped API token. Store credentials in GitHub Actions secrets, never in the repository or browser bundle.

This architecture also fails safely. If the scheduled update breaks, the old JSON remains available and the site keeps rendering. A browser calling the analytics API directly would instead expose credentials or add CORS, latency, and outage handling to every page load.

Display less than you collect

A small blog rarely needs a number on every surface.

Display Use it when
Popular posts in the last week The home page needs a freshness signal
Lifetime views on an article Social proof is genuinely useful
Operator-only JSON You want to validate data before release

Label the period. “128 views” looks like an exact lifetime counter. “128 views in the last seven days” explains the window. Rounding to “read 100+ times this week” may better reflect the uncertainty in analytics data.

Likes are a different system

Page views can be read from an existing aggregate. A like button creates a write at the moment of interaction.

That introduces:

Do not force this into a static JSON workflow. GitHub Discussions, a comment service, Supabase, Firebase, or Cloudflare Workers can provide a writable backend, but the architecture is then “no server operated directly,” not “no backend.”

When this pattern fits

[ ] Real-time counts are unnecessary
[ ] Daily refreshes are acceptable
[ ] Approximate popularity is enough
[ ] Raw events never enter the site repository
[ ] A failed stats refresh must not break the site

Do not use this pattern when the number drives billing, rankings, rewards, or per-user history. Those features require a trusted database and server-side validation.

For a content site, page views work best as data the site only reads: analytics collects it, automation creates the totals, and the static site displays them. Start with the analytics source you already operate, export only URL-level aggregates, and leave real-time interactions out until they justify a backend.

Sources