← Vishnu Vardhan

Counting Visitors on a Static Site

October 2026 · A Cloudflare Worker, one key-value store, and no IP addresses

I wanted one number at the bottom of this site: how many people have read it. Not page loads, not my own visits, and no third-party tracker. It turned out a site like mine can't count anything by itself, so this is how the counter works, the code behind it, and every step I ran to put it live.

Why the site can't count

This site is a Next.js project built as a static export. GitHub Actions runs npm run build, which turns the code into plain HTML, CSS and JavaScript files, and GitHub Pages serves those files exactly as they are. When you open vishnuvardhan.io, GitHub sends you the same file it sends everyone else. No code of mine runs on GitHub's side, so there is nothing there that could add 1 to a number and remember it.

The count can't live in the repository either. Every deploy replaces every file with a fresh build, so a number kept in a file would be overwritten the next time I pushed.

Counting needs two things a file server doesn't have: code that runs on each request, and storage that outlives a deploy. Cloudflare already handles DNS for my domain, and it offers both for free: Workers, small JavaScript functions that run on Cloudflare's servers, and KV, a key-value store that works like a Java Map<String, String> kept on disk.

How one visit is counted

Visitor's browser runs the page's JavaScript GitHub Pages serves the same HTML and JS files to everyone Cloudflare Worker visits.js KV store "total" → "42" 1 GET 2 POST 3 4 {"visits":43}
The site and the counter run in different places. The browser is what connects them.
  1. The browser loads the page from GitHub Pages, as it always did.
  2. The page's JavaScript sends a POST to visits.vishnuvardhan.io, the address of the Worker.
  3. The Worker reads "total" from KV, adds 1 and writes it back.
  4. It replies with the new number, and the page shows it in the footer.

A returning visitor sends a GET instead, and the Worker only reads. Deciding which request to send is the page's job, covered further down.

The counter

This is the whole Worker, worker/visits.js in the site's repository:

const CORS = {
  "Access-Control-Allow-Origin": "https://vishnuvardhan.io",
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
  "Content-Type": "application/json",
  "Cache-Control": "no-store",
};

export default {
  async fetch(request, env) {
    if (request.method === "OPTIONS") return new Response(null, { headers: CORS });

    let count = Number(await env.VISITS.get("total")) || 0;

    if (request.method === "POST") {
      count += 1;
      await env.VISITS.put("total", String(count));

      const country = request.cf?.country || "Unknown";
      const city = request.cf?.city || "Unknown";
      const places = JSON.parse((await env.VISITS.get("places")) || "{}");
      const c = (places[country] ||= { total: 0, cities: {} });
      c.total += 1;
      c.cities[city] = (c.cities[city] || 0) + 1;
      await env.VISITS.put("places", JSON.stringify(places));
    }

    return new Response(JSON.stringify({ visits: count }), { headers: CORS });
  },
};

Cloudflare calls fetch once for every request that reaches the Worker, the way a servlet container calls service(). env.VISITS is the KV store, injected by Cloudflare from the configuration below. Piece by piece:

Who gets counted

The Worker counts every POST it receives, so the page decides when to send one. Three rules, all checked in the visitor's browser:

function shouldCount(): boolean {
  const params = new URLSearchParams(location.search);
  if (params.has("me")) {
    if (params.get("me") === "off") localStorage.removeItem("owner");
    else localStorage.setItem("owner", "1");
  }
  if (location.hostname !== "vishnuvardhan.io") return false; // local dev
  if (localStorage.getItem("owner")) return false;            // my own browsers
  if (localStorage.getItem("counted")) return false;          // counted before
  return true;
}

fetch("https://visits.vishnuvardhan.io", { method: shouldCount() ? "POST" : "GET" })
  .then((r) => r.json())
  .then((d) => { /* show d.visits; after a POST, set "counted" */ });

The same few lines run on every article page too, because most readers arrive on an article from a link, not on the homepage. Article pages count without showing the number; the homepage footer shows it.

Putting it live

Everything below runs from the worker folder with npx wrangler, Cloudflare's command-line tool. npx downloads it on first use, so there is nothing to install. I used Git Bash on Windows; the odd characters around the version number in my screenshots are Git Bash failing to draw wrangler's emoji, and they change nothing.

1. Sign in

npx wrangler login

The Worker and its storage have to be created inside my Cloudflare account, the one that owns the domain, so wrangler needs permission to act on it. This opens the Cloudflare sign-in page in the browser. After I approved it, Cloudflare gave wrangler a token, saved on my laptop, the same way git push works once you've signed in to GitHub. It can be revoked any time under My Profile → Access Management → Connected Applications.

Cloudflare page: Authorization granted to Wrangler
The browser after approving wrangler's access.

2. Create the storage

npx wrangler kv namespace create VISITS

This creates an empty KV store, the map that will hold "total" and "places", and prints its id. The id is how the Worker finds this store and not another one.

Git Bash: wrangler login succeeded, then KV namespace VISITS created with its id
Step 1 finishing with “Successfully logged in”, then step 2 printing the new store's id.

3. Connect the code to the storage

The id goes into worker/wrangler.toml, the Worker's configuration:

name = "vishnuvardhan-visits"
main = "visits.js"
compatibility_date = "2026-01-01"

routes = [{ pattern = "visits.vishnuvardhan.io", custom_domain = true }]

[[kv_namespaces]]
binding = "VISITS"
id = "5160c8dadf2c476ca36de37bae721e75"

binding = "VISITS" is why the code can say env.VISITS. routes asks Cloudflare to serve the Worker at visits.vishnuvardhan.io, a fixed address on my own domain, which only works because the domain is already on Cloudflare. The id is safe to commit: it names the store but is useless without my Cloudflare login.

4. Deploy

npx wrangler deploy

This uploads visits.js to Cloudflare and creates the address. The output confirms all three parts: the binding to the store, the upload, and the custom domain.

Git Bash: wrangler deploy output listing env.VISITS binding and visits.vishnuvardhan.io custom domain
The whole counter is 1.52 KiB. “env.VISITS” shows the code is linked to the store from step 2.

5. Check it answers

Opening the address in a browser sends a GET, so the Worker reads the total and changes nothing:

Browser at visits.vishnuvardhan.io showing visits: 0
The counter, live, before its first visitor.

Two small steps finish the setup: opening vishnuvardhan.io/?me once in each browser I use, and committing wrangler.toml so a future deploy connects to the same store.

What survives a redeploy

The three parts live in three places, and each deploy replaces only its own:

PartWhere it livesReplaced by
The site's pagesGitHub Pagesevery git push
The counter codeCloudflare Workersnpx wrangler deploy
The countCloudflare KVnothing; it stays until the store is deleted

To keep a copy of the number anyway, npx wrangler kv key get total --binding VISITS --remote prints it.

Where visitors come from

Cloudflare looks up every request's IP address in its location data before the Worker runs, and attaches the result as request.cf: country, region, city, postal code and approximate coordinates. The Worker keeps only running totals per country and city, and never stores the IP itself. I read them in the Cloudflare dashboard under Storage & Databases → KV → VISITS, in the places key; they are not shown on the site.

The lookup works from the internet provider's address blocks, so its accuracy falls with the size of the area. The country is nearly always right. The city is where the provider assigns that block, which is often a nearby larger city: a reader in a small town can show up under the closest big one, and phones on mobile data often show the carrier's hub. I treat a city as "roughly that part of the map".

Limits

Sources