Counting Visitors on a Static Site
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
- The browser loads the page from GitHub Pages, as it always did.
- The page's JavaScript sends a POST to
visits.vishnuvardhan.io, the address of the Worker. - The Worker reads
"total"from KV, adds 1 and writes it back. - 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:
- The
CORSheaders. The site and the counter are different addresses, and browsers block a page from reading answers from another address unless that address allows it. These headers allow vishnuvardhan.io, and only vishnuvardhan.io, to read the count.no-storestops browsers from caching an old number. - The
OPTIONScheck. Before a POST to another address, the browser first asks whether it's allowed. The Worker answers with the headers and counts nothing. - Reading the total. KV stores text, so the value is converted to a number. Before the first visitor the key doesn't exist, and
|| 0turns that into 0. - The
POSTblock. Adds 1 and saves the total. Then it adds 1 to the visitor's country and city, kept together as one JSON value under"places".||=creates a country's entry the first time someone visits from there, like Java'scomputeIfAbsent. - The reply. The total as JSON, for example
{"visits":43}.
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" */ });
- Each browser once. After a successful POST, the page saves a
countedflag in the browser's local storage. Refreshing, or coming back next week, sends a GET. - Never my own visits. Opening
vishnuvardhan.io/?mesaves anownerflag in that browser, and from then on it only reads. I did this once in each browser I use.?me=offremoves it. - Only the live site. Running the site locally on
localhostnever counts.
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.
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.
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.
5. Check it answers
Opening the address in a browser sends a GET, so the Worker reads the total and changes nothing:
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:
| Part | Where it lives | Replaced by |
|---|---|---|
| The site's pages | GitHub Pages | every git push |
| The counter code | Cloudflare Workers | npx wrangler deploy |
| The count | Cloudflare KV | nothing; 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
- It counts browsers, not people. A reader who clears their browser data, or switches from laptop to phone, is counted again.
- Writes are capped on the free plan. Cloudflare allows 1,000 KV writes a day, and each new visitor uses two (the total and the places), so roughly 500 new visitors a day. Returning visitors only read, and reads are allowed 100,000 a day.
- It isn't built for simultaneous visits. KV allows one write per second to the same key. Two new visitors in the same second can make one write fail; that visitor isn't counted, and their browser tries again on their next visit.
Sources
- Workers KV limits, for the free plan's daily writes and reads and the one-write-per-second limit per key.
- Workers Request API, for the location fields in
request.cf. - Wrangler command reference, for
login,kv namespace create,deployandkv key get, listed under its Workers and KV sections. - Workers custom domains, for serving the Worker at
visits.vishnuvardhan.io.