Kamero

Build a Visitor Analytics Dashboard with IP Geolocation

Understanding where your visitors come from is essential for making data-driven decisions about content, infrastructure, and marketing. This guide walks through building a lightweight analytics dashboard that tracks visitor geography using IP geolocation โ€” no third-party analytics service required.

Architecture Overview

The system has three parts:

  1. A tracking endpoint that captures visitor geo data on each page view
  2. A storage layer (database or file) for the collected data
  3. A dashboard that aggregates and visualizes the data

Step 1: Collect Geo Data on Page View

// app/api/track/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function POST(request: NextRequest) {
  const geo = await fetch("https://geo.kamero.ai/api/geo")
    .then(r => r.json());

  const event = {
    timestamp: new Date().toISOString(),
    path: request.headers.get("referer") || "/",
    ip: geo.ip,
    city: geo.city,
    country: geo.country,
    continent: geo.continent,
    timezone: geo.timezone,
    latitude: parseFloat(geo.latitude),
    longitude: parseFloat(geo.longitude),
  };

  // Store the event (see storage options below)
  await storeEvent(event);

  return NextResponse.json({ ok: true });
}

Step 2: Client-Side Tracking Snippet

// components/Analytics.tsx
"use client";
import { useEffect } from "react";
import { usePathname } from "next/navigation";

export function Analytics() {
  const pathname = usePathname();

  useEffect(() => {
    // Fire and forget โ€” don't block rendering
    fetch("/api/track", {
      method: "POST",
      keepalive: true,
    }).catch(() => {});
  }, [pathname]);

  return null;
}

// Add to layout.tsx:
// <Analytics />

Step 3: Storage Options

Option A: SQLite (Simple, Self-Contained)

import Database from "better-sqlite3";

const db = new Database("analytics.db");
db.exec(`
  CREATE TABLE IF NOT EXISTS page_views (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    timestamp TEXT NOT NULL,
    path TEXT,
    ip TEXT,
    city TEXT,
    country TEXT,
    continent TEXT,
    timezone TEXT,
    latitude REAL,
    longitude REAL
  )
`);

async function storeEvent(event: PageView) {
  db.prepare(`
    INSERT INTO page_views
    (timestamp, path, ip, city, country,
     continent, timezone, latitude, longitude)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
  `).run(
    event.timestamp, event.path, event.ip,
    event.city, event.country, event.continent,
    event.timezone, event.latitude, event.longitude
  );
}

Option B: JSON File (Zero Dependencies)

import { appendFile } from "fs/promises";

async function storeEvent(event: PageView) {
  await appendFile(
    "analytics.jsonl",
    JSON.stringify(event) + "\n"
  );
}

Step 4: Aggregation Queries

// Top countries
function getTopCountries(days = 30) {
  return db.prepare(`
    SELECT country, COUNT(*) as views
    FROM page_views
    WHERE timestamp > datetime('now', ?)
    GROUP BY country
    ORDER BY views DESC
    LIMIT 20
  `).all(`-${days} days`);
}

// Views by hour (for timezone analysis)
function getViewsByHour() {
  return db.prepare(`
    SELECT strftime('%H', timestamp) as hour,
           COUNT(*) as views
    FROM page_views
    WHERE timestamp > datetime('now', '-7 days')
    GROUP BY hour
    ORDER BY hour
  `).all();
}

// Unique visitors by city
function getTopCities(limit = 50) {
  return db.prepare(`
    SELECT city, country,
           COUNT(DISTINCT ip) as unique_visitors,
           COUNT(*) as total_views
    FROM page_views
    WHERE timestamp > datetime('now', '-30 days')
    GROUP BY city, country
    ORDER BY unique_visitors DESC
    LIMIT ?
  `).all(limit);
}

Step 5: Dashboard API

// app/api/analytics/route.ts
export async function GET() {
  const data = {
    topCountries: getTopCountries(),
    topCities: getTopCities(20),
    viewsByHour: getViewsByHour(),
    totalViews: db.prepare(
      "SELECT COUNT(*) as count FROM page_views"
    ).get(),
    uniqueVisitors: db.prepare(
      "SELECT COUNT(DISTINCT ip) as count FROM page_views"
    ).get(),
  };

  return NextResponse.json(data);
}

Step 6: Visualize with a Map

"use client";
import { useEffect, useState } from "react";

interface CityData {
  city: string;
  country: string;
  unique_visitors: number;
  latitude: number;
  longitude: number;
}

export function VisitorMap() {
  const [cities, setCities] = useState<CityData[]>([]);

  useEffect(() => {
    fetch("/api/analytics")
      .then(r => r.json())
      .then(d => setCities(d.topCities));
  }, []);

  return (
    <div style={{ position: "relative" }}>
      <svg viewBox="0 0 1000 500" className="world-map">
        {/* World map paths here */}
        {cities.map((city, i) => {
          // Convert lat/lng to SVG coordinates
          const x = ((city.longitude + 180) / 360) * 1000;
          const y = ((90 - city.latitude) / 180) * 500;
          const r = Math.min(
            Math.sqrt(city.unique_visitors) * 2, 20
          );

          return (
            <circle
              key={i}
              cx={x} cy={y} r={r}
              fill="rgba(59, 130, 246, 0.6)"
              stroke="rgba(59, 130, 246, 0.9)"
              strokeWidth={1}
            >
              <title>
                {`${city.city}, ${city.country}: ${city.unique_visitors} visitors`}
              </title>
            </circle>
          );
        })}
      </svg>
    </div>
  );
}

Step 7: Country Bar Chart

function CountryChart({
  data
}: {
  data: { country: string; views: number }[]
}) {
  const max = Math.max(...data.map(d => d.views));

  return (
    <div className="chart">
      {data.slice(0, 10).map((item) => (
        <div key={item.country} className="chart-row">
          <span className="chart-label">
            {item.country}
          </span>
          <div className="chart-bar-container">
            <div
              className="chart-bar"
              style={{
                width: `${(item.views / max) * 100}%`
              }}
            />
          </div>
          <span className="chart-value">
            {item.views.toLocaleString()}
          </span>
        </div>
      ))}
    </div>
  );
}

Privacy Considerations

Key Takeaways

Start Tracking

Get visitor location data with a single API call โ€” free, no key required.

View Documentation โ†’