context
i prefer keeping this site as a collection of static files—no database, no tracking, just plain markdown but then i got an itch to find out the view count of each post as cloudflare analytics didn’t seem trustworthy to me (yes im extremely paranoid)
adding dynamic features to a static site usually involves heavy third-party scripts or external databases i wanted something more “native” to the stack—a way to build a bridge between static content and dynamic data without leaving the cloudflare ecosystem or compromising on minimalism
so, i made gemini build a custom view counter for me and here is how it works
the architecture
the system relies on cloudflare pages functions—serverless handlers that run on the edge these functions have direct access to cloudflare kv, a low-latency key-value store
- frontend: a small javascript snippet in the post template triggers on page load
- backend: a function at
/api/viewreceives the request and identifies the path - database: cloudflare kv increments the counter and remembers the visitor
the backend logic
the core of the system is the onRequest handler it extracts the page path and the user’s ip address from the request headers
export async function onRequest(context) {
const { request, env } = context;
const url = new URL(request.url);
const path = url.searchParams.get("path");
const ip = request.headers.get("CF-Connecting-IP") || "unknown";
// ... logic ...
}
we then generate a unique hash using the web crypto api this allows us to track “seen” status without actually storing sensitive ip addresses
const msgUint8 = new TextEncoder().encode(ip + path);
const hashBuffer = await crypto.subtle.digest("SHA-256", msgUint8);
const hashHex = Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
finally, we interact with cloudflare kv we check if a key for this specific hash exists if not, we increment the global counter for that path and save the hash permanently
const seenKey = `seen:${path}:${hashHex}`;
const totalKey = `views:${path}`;
if (!(await env.STATS_KV.get(seenKey))) {
const current = (await env.STATS_KV.get(totalKey)) || "0";
await env.STATS_KV.put(totalKey, (parseInt(current) + 1).toString());
await env.STATS_KV.put(seenKey, "1");
}
the frontend integration
on the client side, we use a simple fetch call it identifies the current page using window.location.pathname and updates a placeholder <span> once the data arrives
fetch("/api/view?path=" + encodeURIComponent(window.location.pathname))
.then((response) => response.json())
.then((data) => {
if (data.views) {
document.getElementById("view-count").textContent =
" • views: " + data.views;
}
});
to prevent errors during local development, we added a check for localhost that displays a mock value instead of calling the missing api
why cloudflare functions
it’s a minimalist approach to analytics no cookies, no tracking pixels, and zero impact on performance the data belongs to the owner, and the implementation stays within the existing infrastructure
how cloudflare functions work
cloudflare pages functions are built on v8 isolates unlike traditional servers that run on virtual machines, isolates start up in milliseconds, eliminating “cold starts”
the routing is filesystem-based—since our script is at /functions/api/view.js, it automatically becomes an endpoint at /api/view when a request comes in, the nearest cloudflare data center executes the code, interacts with the bound kv (key-value) namespace, and returns the result this hybrid approach gives a static site dynamic, stateful capabilities without the overhead of a dedicated backend you can check the full implementation in the zeroday repository
comments