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:
- A tracking endpoint that captures visitor geo data on each page view
- A storage layer (database or file) for the collected data
- 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
- Hash or truncate IP addresses before storage to protect privacy
- Aggregate data at the city level rather than storing exact coordinates
- Add a retention policy โ delete raw data after 90 days
- Respect Do Not Track headers and cookie consent preferences
- See our GDPR compliance guide for more details
Key Takeaways
- IP geolocation gives you visitor geography without third-party analytics
- SQLite is a solid choice for self-hosted analytics storage
- Aggregate data server-side to keep dashboards fast
- Always consider privacy โ hash IPs and set retention policies
Start Tracking
Get visitor location data with a single API call โ free, no key required.
View Documentation โ