Real-time analytics • Privacy-friendly • Free to host on Cloudflare
Go to dash.cloudflare.com/sign-up and sign up for a free account.
After signing up, log in and make sure you can access the Workers & Pages section.
curl -fsSL https://bun.com/install | bash
Verify installation:
bun --version
mkdir visitor-counter && cd visitor-counter
visitor-counter/
├── package.json
├── wrangler.json
├── public/
│ ├── index.html
│ └── widget.js
└── src/
└── index.ts
{
"name": "visitor-counter",
"main": "src/index.ts",
"compatibility_date": "2026-06-26",
"durable_objects": {
"bindings": [
{ "name": "APP", "class_name": "App" }
]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["App"] }
]
}
{
"name": "visitor-counter",
"version": "1.0.0",
"type": "module",
"dependencies": {
"hono": "^4.6.0"
},
"devDependencies": {
"wrangler": "^4.105.0"
}
}
import { DurableObject } from "cloudflare:workers";
import { Hono } from "hono";
import { cors } from "hono/cors";
export class App extends DurableObject {
private app = new Hono();
constructor(ctx: DurableObjectState, env: any) {
super(ctx, env);
// Initialize SQL schema
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS visits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
visitor_hash TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_timestamp ON visits(timestamp);
`);
this.app.use("*", cors());
// Record a visit
this.app.get("/hit", async (c) => {
const ip = c.req.header("cf-connecting-ip") || "unknown";
const ua = c.req.header("user-agent") || "unknown";
const visitorHash = await this.hash(`${ip}-${ua}`);
const now = Date.now();
const thirtyMinsAgo = now - 30 * 60 * 1000;
const recentVisit = this.ctx.storage.sql.exec(
`SELECT id FROM visits WHERE visitor_hash = ? AND timestamp > ? LIMIT 1`,
visitorHash,
thirtyMinsAgo
).toArray();
if (recentVisit.length === 0) {
this.ctx.storage.sql.exec(
`INSERT INTO visits (timestamp, visitor_hash) VALUES (?, ?)`,
now,
visitorHash
);
}
return c.json({ ok: true });
});
// Get statistics
this.app.get("/stats", (c) => {
const now = Date.now();
const twentyFourHoursAgo = now - 24 * 60 * 60 * 1000;
const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000;
const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;
const stats = this.ctx.storage.sql.exec(`
SELECT
(SELECT COUNT(*) FROM visits WHERE timestamp > ?) as last_24h,
(SELECT COUNT(*) FROM visits WHERE timestamp > ?) as last_7d,
(SELECT COUNT(*) FROM visits WHERE timestamp > ?) as last_30d,
(SELECT COUNT(*) FROM visits) as total
`, twentyFourHoursAgo, sevenDaysAgo, thirtyDaysAgo).one();
return c.json(stats);
});
}
async hash(text: string) {
const msgUint8 = new TextEncoder().encode(text);
const hashBuffer = await crypto.subtle.digest("SHA-256", msgUint8);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
}
async fetch(request: Request) {
return this.app.fetch(request);
}
}
// ==================== WORKER ENTRYPOINT ====================
export default {
async fetch(request: Request, env: any) {
const url = new URL(request.url);
const id = env.APP.idFromName("visitor-counter"); // Change name if needed
const stub = env.APP.get(id);
// Forward all requests to the Durable Object
return stub.fetch(request);
},
} as ExportedHandler;
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Visitor Counter Demo</title>
<style>...</style>
</head>
<body>
<div id="my-visitor-counter"></div>
<script src="https://your-worker.workers.dev/widget.js" data-container="my-visitor-counter"></script>
</body>
</html>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
background-color: #f6f8fa;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
}
.content {
text-align: center;
max-width: 600px;
padding: 2rem;
background: white;
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0,0,0,0.05);
}
h1 { margin-top: 0; color: #24292e; }
p { color: #586069; line-height: 1.5; }
.widget-container {
margin-top: 2rem;
display: flex;
justify-content: center;
}
code {
background: #f1f1f1;
padding: 2px 4px;
border-radius: 4px;
font-size: 0.9em;
}
(function() {
const script = document.currentScript;
const containerId = script.getAttribute('data-container') || 'visitor-counter';
// Use the script's own URL to determine the base API URL
const scriptUrl = new URL(script.src);
const baseUrl = scriptUrl.origin + scriptUrl.pathname.replace('/widget.js', '');
async function init() {
// 1. Record the hit
try {
await fetch(`${baseUrl}/hit`);
} catch (e) {
console.error('Visitor counter: failed to record hit', e);
}
// 2. Fetch stats
try {
const res = await fetch(`${baseUrl}/stats`);
const stats = await res.json();
render(stats);
} catch (e) {
console.error('Visitor counter: failed to fetch stats', e);
}
}
function render(stats) {
const container = document.getElementById(containerId);
if (!container) return;
const formatter = new Intl.NumberFormat();
container.innerHTML = `
Analytics
24h
${formatter.format(stats.last_24h)}
7d
${formatter.format(stats.last_7d)}
30d
${formatter.format(stats.last_30d)}
Total
${formatter.format(stats.total)}
`;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
bun installbun add -d wranglerbunx wrangler deploy