🚀 Deploy Your Own Visitor Counter Widget in Under 30 Minutes

Real-time analytics • Privacy-friendly • Free to host on Cloudflare

Step 1: Create a Free Cloudflare Account

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.

Step 2: Install Bun

curl -fsSL https://bun.com/install | bash

Verify installation:

bun --version

Step 3: Create Project Folder

mkdir visitor-counter && cd visitor-counter

Step 4: Project Structure

visitor-counter/
├── package.json
├── wrangler.json
├── public/
│   ├── index.html
│   └── widget.js
└── src/
    └── index.ts

wrangler.json

{
  "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"] }
  ]
}

package.json

{
  "name": "visitor-counter",
  "version": "1.0.0",
  "type": "module",
  "dependencies": {
    "hono": "^4.6.0"
  },
  "devDependencies": {
    "wrangler": "^4.105.0"
  }
}

src/index.ts

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;

public/index.html

<!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>
        
        
      

Copy CSS styling for index.html file and place between

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;
        }

public/widget.js

(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(); } })();

Final Steps

  1. Run bun install
  2. Run bun add -d wrangler
  3. Run bunx wrangler deploy
  4. Next go to the deployed URL from your worker to get the code snippet and embed the widget into your HTML file, where you want it displayed.
  5. Free, Open Source and Customizable.
Need Help? Contact Our Web Team