Building Custom Analytics: Audience Intelligence for a Public Website
When you run a publicly accessible website, understanding your audience matters for two reasons.
Performance: Knowing which pages people visit, what devices they use, and where they come from helps you make better decisions about what to build, what to optimize, and what content resonates. If 90% of your visitors are on mobile and your site only looks good on desktop, that's a problem you can't see without data.
Security: A public website is an attack surface. Bots, scrapers, vulnerability scanners, and credential stuffers hit public sites constantly. Being able to distinguish real human traffic from automated noise, and seeing patterns like repeated requests from hosting/datacenter IPs or known proxy networks, is a basic hygiene practice for anyone running a production site. If someone is probing your endpoints or scraping your content, you want to know about it.
Most hosted analytics tools give you the first part (aggregate traffic data) but not the second. I wanted both: a clean dashboard with visualizations for understanding my audience, plus per-visitor detail with enough depth to spot suspicious activity. This is the story of building that system from scratch with Claude Code.
Starting with Vercel's Built-in Analytics#
Before building anything custom, I enabled Vercel's native analytics tools. If you're on Vercel, these are worth turning on regardless of whether you build custom tracking.
Vercel Web Analytics#
Vercel Web Analytics gives you aggregate traffic data: page views, unique visitors, top pages, referrers, countries, browsers, and OS. It's privacy-focused by design: no cookies, no IP tracking, no per-visitor data. You add one component to your layout:
import { Analytics } from "@vercel/analytics/next";
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Analytics />
</body>
</html>
);
}
After deploying, enable it in your Vercel project dashboard under Analytics > Web Analytics. The toggle and the component are both required, and one without the other won't work.
Vercel Speed Insights#
Speed Insights tracks Core Web Vitals: Largest Contentful Paint (LCP), First Input Delay (FID), Cumulative Layout Shift (CLS), and others. This tells you how fast your site feels for real users, broken down by route and device type. Same pattern:
import { SpeedInsights } from "@vercel/speed-insights/next";
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<SpeedInsights />
</body>
</html>
);
}
Enable it separately in the Vercel dashboard under Speed Insights. Both tools are independent toggles.
The Ad Blocker Problem#
Significant Blind Spot
Ad blockers kill both of these. uBlock Origin, Brave shields, and most privacy extensions block Vercel's analytics scripts (/_vercel/insights/script.js and /_vercel/speed-insights/script.js) the same way they block Google Analytics. In my testing, I saw ERR_BLOCKED_BY_CLIENT in the DevTools Network tab, and the scripts never load.
This means your Vercel analytics data has a blind spot. Any visitor running an ad blocker (which is a significant chunk of the tech-savvy audience that visits a dev blog) is invisible to Vercel's tools. The aggregate numbers will be lower than reality, and the demographic breakdown will be skewed toward less technical users who don't run blockers.
This is a major reason I built custom server-side tracking. A sendBeacon() POST to your own API route on your own domain doesn't get blocked by ad blockers. It looks like a regular same-origin request, not a third-party analytics script.
Why Not Stop Here?#
Vercel Web Analytics is great for what it does, but it doesn't expose:
- Individual IP addresses (needed for security analysis)
- Per-visitor session data (needed to spot bot patterns)
- Geolocation coordinates (needed for a visitor map)
- Raw data you can query however you want
For a personal portfolio site where I want to understand exactly who's visiting and spot anomalies, I needed the raw data layer underneath.
The Custom Tracking Architecture#
Here's what we built:
Browser (page load)
|
|-- sendBeacon() --> POST /api/analytics/track
|
|-- Read IP from x-forwarded-for header
|-- Read geo from x-vercel-ip-* headers
|-- Parse User-Agent string
|-- INSERT INTO page_views (Neon Postgres)
|
Dashboard (/analytics), protected by cookie auth
|
|-- Server Component queries Neon directly
|-- Renders charts, map, tables, raw visitor log
|-- Click any IP → OSINT intelligence panel
Authentication Update
The analytics dashboard is now protected by httpOnly cookie authentication using HMAC-SHA256 tokens, accessed via a login page at /analytics/login. The original query-string secret approach (?secret=...) was replaced after a security audit identified it as a CRITICAL finding. See I Audited My Own Code for the full story.
The system has grown to include these files:
| File | Purpose |
|---|---|
src/lib/analytics.ts | Database connection, TypeScript types, User-Agent parser |
src/lib/analytics-types.ts | Shared TypeScript interfaces for all analytics data |
src/lib/analytics-auth.ts | HMAC-SHA256 cookie auth with timingSafeEqual |
src/components/analytics-tracker.tsx | Client component that fires tracking beacon |
src/app/api/analytics/track/route.ts | POST endpoint that records visits |
src/app/api/analytics/setup/route.ts | One-time table creation endpoint |
src/app/api/analytics/ip-intel/route.ts | IP OSINT lookup with caching |
src/app/analytics/page.tsx | Dashboard server component |
src/app/analytics/login/page.tsx | Cookie-based login page |
src/app/analytics/_components/ | 12 chart, map, and table components |
The Client-Side Tracker#
The tracking component is a "use client" React component that lives in the root layout. It renders nothing visible. It's purely a side effect. On every page navigation, it sends a POST request to the tracking API:
"use client";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
export function AnalyticsTracker() {
const pathname = usePathname();
useEffect(() => {
const payload = JSON.stringify({ path: pathname });
if (navigator.sendBeacon) {
navigator.sendBeacon(
"/api/analytics/track",
new Blob([payload], { type: "application/json" })
);
} else {
fetch("/api/analytics/track", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload,
keepalive: true,
}).catch(() => {});
}
}, [pathname]);
return null;
}
A few things worth noting here:
Why sendBeacon()
The sendBeacon API is non-blocking. The browser sends the request in the background without waiting for a response. It also survives page unloads, meaning if a user clicks a link and navigates away, the beacon still gets delivered. Regular fetch requests can get cancelled during navigation.
Why usePathname()? Next.js App Router uses client-side navigation. When you click a link, there's no full page reload. React swaps the content. The usePathname() hook re-fires whenever the URL changes, so we track every navigation, not just the initial page load.
What does the client send? Just the page path. Nothing sensitive. All the detailed data (IP, geo, user agent) gets extracted server-side from request headers. The client doesn't need to know about any of it.
Ad Blocker Resistance
This is a same-origin POST to your own API route. It doesn't load an external script, it doesn't hit a third-party domain, and it doesn't match the filter lists that ad blockers use. The request looks identical to any other API call your application makes.
The Tracking API Route#
When the beacon arrives, the API route reads headers that Vercel injects into every request:
// Extract IP address
// x-forwarded-for may contain: "client, proxy1, proxy2"
const forwardedFor = request.headers.get("x-forwarded-for");
const realIp = request.headers.get("x-real-ip");
const ipAddress = forwardedFor
? forwardedFor.split(",")[0].trim()
: realIp || "127.0.0.1";
// Extract Vercel geolocation headers
const country = decodeURIComponent(
request.headers.get("x-vercel-ip-country") || "Unknown"
);
const city = decodeURIComponent(
request.headers.get("x-vercel-ip-city") || "Unknown"
);
const region = decodeURIComponent(
request.headers.get("x-vercel-ip-country-region") || "Unknown"
);
const latitude = request.headers.get("x-vercel-ip-latitude") || "";
const longitude = request.headers.get("x-vercel-ip-longitude") || "";
How Vercel's geo headers work: When a request hits Vercel's edge network, Vercel resolves the client IP to a geographic location and injects headers before forwarding to your serverless function:
x-forwarded-for, the client's IP address (first in the comma-separated list)x-real-ip, single client IP (Vercel-specific)x-vercel-ip-country, ISO country code like "US"x-vercel-ip-country-region, state/region code like "FL"x-vercel-ip-city, city name (URL-encoded)x-vercel-ip-latitudeandx-vercel-ip-longitude, coordinates
Local Development
These headers only exist when deployed to Vercel. In local development, you'll see 127.0.0.1 and Unknown for everything.
The Lightweight User-Agent Parser#
Instead of pulling in a heavy library like ua-parser-js (17 KB), Claude built a lightweight regex-based parser. It covers about 95% of real traffic:
export function parseBrowser(ua: string): string {
if (!ua) return "Unknown";
// Order matters - check specific browsers before generic engines
const browsers: [RegExp, string][] = [
[/Edg(?:e|A|iOS)?\/(\d+)/, "Edge"],
[/OPR\/(\d+)/, "Opera"],
[/Firefox\/(\d+)/, "Firefox"],
[/CriOS\/(\d+)/, "Chrome iOS"],
[/Chrome\/(\d+)/, "Chrome"],
[/Version\/(\d+).*Safari/, "Safari"],
];
for (const [regex, name] of browsers) {
const match = ua.match(regex);
if (match) return `${name} ${match[1]}`;
}
if (/bot|crawl|spider/i.test(ua)) return "Bot";
return "Other";
}
Order Matters
Chrome's User-Agent string contains "Safari" (for historical compatibility reasons). If you check for Safari first, every Chrome user gets misidentified. Edge contains "Chrome" in its UA string. You have to check from most specific to least specific.
The parser also detects OS (Windows, macOS, Linux, iOS, Android), device type (Desktop, Mobile, Tablet, Bot), and version numbers. On the security side, knowing the device type helps distinguish legitimate traffic from bots. A "Desktop" visitor with a bot-like User-Agent string is worth investigating.
The Database: Neon Serverless Postgres#
For storage, I went with Neon, a serverless Postgres provider with a generous free tier (0.5 GB storage). The key feature is their serverless driver (@neondatabase/serverless), which sends queries over HTTP instead of maintaining a persistent TCP connection. This is perfect for Vercel's serverless functions because:
- No connection pool management. Each function invocation creates a fresh HTTP connection
- Zero cold-start penalty. No TCP/TLS handshake to wait for
- Neon handles pooling. Connection pooling happens on Neon's infrastructure
SQL Injection Prevention
The driver uses tagged template literals for automatic parameterization. The ${ip} in the template literal is NOT string interpolation - it's a parameter placeholder that gets sent separately from the SQL string, preventing SQL injection.
import { neon } from "@neondatabase/serverless";
export function getDb() {
let databaseUrl = process.env.DATABASE_URL;
// ... validation and sanitization ...
return neon(databaseUrl);
}
// Usage - parameters are automatically escaped
const sql = getDb();
await sql`
INSERT INTO page_views (page_path, ip_address, browser)
VALUES (${pagePath}, ${ipAddress}, ${browser})
`;
The Dashboard: Visualizations#
The original dashboard was all HTML tables, functional but not easy to scan at a glance, so I added interactive visualizations using Recharts (via shadcn/ui's chart component) and Leaflet (via react-leaflet) for a world map.

Page Views Over Time#
An area chart showing daily views and unique visitors over the selected time period. This is the first thing you see on the dashboard. It immediately tells you whether traffic is trending up, down, or staying flat:
"use client";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
const chartConfig = {
views: { label: "Page Views", color: "var(--chart-1)" },
unique_visitors: { label: "Unique Visitors", color: "var(--chart-2)" },
};
export function PageViewsChart({ data }: { data: DailyViews[] }) {
return (
<ChartContainer config={chartConfig} className="h-[300px] w-full">
<AreaChart data={data}>
<defs>
<linearGradient id="fillViews" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--chart-1)" stopOpacity={0.3} />
<stop offset="95%" stopColor="var(--chart-1)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<ChartTooltip content={<ChartTooltipContent />} />
<Area dataKey="views" fill="url(#fillViews)" stroke="var(--chart-1)" />
<Area dataKey="unique_visitors" fill="url(#fillUnique)" stroke="var(--chart-2)" />
</AreaChart>
</ChartContainer>
);
}
The ChartContainer from shadcn/ui wraps Recharts and injects CSS custom properties for theme-aware colors. The gradient fill gives the chart depth without overwhelming the data.
Visitor World Map#
A Leaflet map with dark CartoDB tiles and circle markers scaled by visit count. This shows geographic distribution at a glance, useful for understanding whether your audience is local, domestic, or international:

"use client";
import { MapContainer, TileLayer, CircleMarker, Tooltip } from "react-leaflet";
import "leaflet/dist/leaflet.css";
export function VisitorMap({ data }: { data: MapLocation[] }) {
return (
<MapContainer
center={[20, 0]}
zoom={2}
style={{ height: "100%", width: "100%", background: "#1a1a2e" }}
>
<TileLayer
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
/>
{data.map((loc) => (
<CircleMarker
center={[parseFloat(loc.latitude), parseFloat(loc.longitude)]}
radius={getRadius(loc.views, maxViews)}
pathOptions={{ fillColor: "oklch(0.75 0.15 195)", fillOpacity: 0.6 }}
>
<Tooltip>{loc.city}, {loc.country}: {loc.views} views</Tooltip>
</CircleMarker>
))}
</MapContainer>
);
}
SSR Compatibility
Leaflet accesses the browser's window object, which doesn't exist during server-side rendering. You must wrap the map component with next/dynamic and ssr: false:
const VisitorMapInner = dynamic(
() => import("./visitor-map").then((mod) => mod.VisitorMap),
{ ssr: false, loading: () => <div>Loading map...</div> }
);
Donut Charts and Bar Charts#
The dashboard also includes donut charts for browser, device, and OS distribution, plus horizontal bar charts for top pages and countries. Each chart component is a "use client" file that receives typed data as props from the server component:

The donut charts show a center total label so you can see both the distribution and the absolute numbers at a glance. The bar charts cap at 10 items to keep things readable. Below the visualizations, the same data is available in detailed tables for when you need exact numbers.
Dashboard Layout#
The full layout flows from top to bottom: stat cards, area chart, world map, chart grid, data tables, and the recent visits log. The server component runs nine queries in parallel:
const [
summary, topPages, topCountries, browsers, devices,
osStats, recent, dailyViews, mapLocations,
] = await Promise.all([
sql`SELECT COUNT(*)::int AS total_views, ... FROM page_views WHERE ...`,
sql`SELECT page_path, COUNT(*)::int AS views ... GROUP BY page_path ...`,
// ... 7 more queries
]);
All nine queries hit the database concurrently through Neon's HTTP driver. The dashboard renders in a single server-side pass with no loading states.
IP Intelligence: The OSINT Panel#
This is the security-focused feature. In the Recent Visits table, every IP address is clickable. Clicking one opens a slide-out panel that performs a three-step intelligence lookup:

Step 1: ip-api.com (Network Intelligence)#
The first lookup hits ip-api.com (free, 45 requests/minute, no API key). This returns:
- ISP and Organization, who owns the IP range
- AS Number and Name, the autonomous system (network operator)
- Proxy/VPN flag, is this a known proxy or VPN exit node?
- Hosting/DC flag, is this a hosting provider or datacenter IP?
- Mobile flag, is this a mobile carrier IP?
- Geolocation, country, city, region, coordinates
Traffic Classification
The proxy and hosting flags are particularly useful from a security perspective. Legitimate visitors generally come from residential or mobile IPs. Traffic from hosting providers, datacenters, or known VPN exit nodes is more likely to be automated.
Step 2: RDAP (WHOIS Registration Data)#
The second lookup queries rdap.org for WHOIS registration data. RDAP (Registration Data Access Protocol) is the modern replacement for the old WHOIS protocol. It returns structured JSON instead of freeform text:
async function fetchRdap(ip: string): Promise<RdapResult> {
const res = await fetch(`https://rdap.org/ip/${encodeURIComponent(ip)}`, {
headers: { Accept: "application/rdap+json" },
signal: AbortSignal.timeout(8000),
});
const data = await res.json();
// Extract org name and address from vCard entities
for (const entity of data.entities || []) {
if (entity.vcardArray) {
const vcard = entity.vcardArray[1] || [];
for (const entry of vcard) {
if (entry[0] === "fn") org = entry[3];
if (entry[0] === "adr") address = entry[3].filter(Boolean).join(", ");
}
}
}
return { org, address };
}
This gives you the registered organization name and address for the IP range, the company that actually owns the network block.
Step 3: Nominatim (Reverse Geocoding)#
The third lookup takes the latitude/longitude from Step 1 and reverse-geocodes it to an approximate street address using OpenStreetMap's Nominatim service (free, 1 request/second):
const res = await fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=16`,
{ headers: { "User-Agent": "CryptoFlexAnalytics/1.0" } }
);
Caching#
All three lookups are cached in an ip_intel database table with a 7-day TTL. The API route checks the cache first. If a cached result exists and isn't expired, it returns immediately without hitting any external APIs:
SELECT * FROM ip_intel
WHERE ip_address = $1
AND cached_at > NOW() - INTERVAL '7 days'
On cache miss, it performs all three lookups and upserts the result. This keeps the external API calls to a minimum. You only hit the rate limits when looking up new IPs.
The Panel UI#
The OSINT panel displays:
- Status badges, VPN/Proxy, Hosting/DC, Mobile, or Residential
- Network details, ISP, org, AS number, AS name
- WHOIS registration, registered org name and address
- Approximate location, reverse-geocoded address, coordinates
- Property lookup link, a best-effort link to the county property appraiser for the IP's geographic area
All data sources are free with no API keys required. The only constraint is rate limits, which the caching layer handles.
The Troubleshooting Saga#
Building the code was the easy part. Getting the database connected in production took longer than writing the entire analytics system.
Problem 1: The Neon Connection String#
Copy Button Gotcha
When you create a Neon project, the dashboard shows your connection string. There's a handy "Copy" button. But Neon's default copy gives you the psql CLI command, not the raw connection string.
psql 'postgresql://user:password@host/database?sslmode=require'
The neon() driver expects just the URL:
postgresql://user:password@host/database?sslmode=require
I pasted the full CLI command into my Vercel environment variable and got:
{
"error": "Failed to create table",
"details": "Database connection string provided to `neon()` is not a valid URL."
}
The Fix: Defensive Sanitization#
Handle Both Formats
Rather than relying on users (including myself) to paste the right format, I added a sanitization step that strips the psql prefix and surrounding quotes if present. Now both formats work.
export function getDb() {
let databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error("DATABASE_URL environment variable is not set.");
}
// Strip "psql" prefix and surrounding quotes if present
databaseUrl = databaseUrl
.replace(/^psql\s+/, "")
.replace(/^'|'$/g, "");
return neon(databaseUrl);
}
Problem 2: Environment Variables Need a Redeploy#
Vercel Fundamental
Changing an environment variable does NOT affect running deployments. You must redeploy for the new value to take effect. The Vercel dashboard updates instantly, but your serverless functions still run with values baked in at build time.
What Each Visit Captures#
Here's the full list of data points stored for every page view:
| Field | Source | Example |
|---|---|---|
| Timestamp | Server clock | 2026-02-08T15:30:00Z |
| Page path | Client beacon | /blog/my-post |
| IP address | x-forwarded-for header | 73.215.xxx.xxx |
| Country | x-vercel-ip-country | US |
| City | x-vercel-ip-city | Jacksonville |
| Region | x-vercel-ip-country-region | FL |
| Lat/Long | x-vercel-ip-latitude/longitude | 30.33, -81.66 |
| Browser | Parsed from User-Agent | Chrome 120 |
| OS | Parsed from User-Agent | Windows 10/11 |
| Device type | Parsed from User-Agent | Desktop |
| Referrer | Referer header | google.com or (direct) |
All extracted server-side from standard HTTP headers. The client only sends the page path. No cookies, no fingerprinting, no localStorage, no third-party scripts.
The Setup Process#
For anyone who wants to replicate this:
Step 1: Install dependencies#
npm install @neondatabase/serverless
npm install recharts react-leaflet leaflet
npm install -D @types/leaflet
npx shadcn@latest add chart sheet badge
Step 2: Create a Neon project#
Go to neon.tech, sign up (free), create a project. Copy the connection string. Make sure you get just the URL starting with postgresql://.
Step 3: Add environment variables in Vercel#
DATABASE_URL, your Neon connection stringANALYTICS_SECRET, a random string for dashboard authentication (openssl rand -hex 32)
Step 4: Enable Vercel Analytics#
In your Vercel project dashboard, enable Web Analytics and Speed Insights separately. Add both components to your root layout. These work alongside the custom tracking. They're complementary, not redundant.
Step 5: Deploy and initialize#
After deploying, visit your setup endpoint once (requires ANALYTICS_SETUP_ENABLED=true in environment variables):
https://yoursite.com/api/analytics/setup
This creates both the page_views table and the ip_intel cache table. Remove the ANALYTICS_SETUP_ENABLED variable after initialization.
Step 6: Access your dashboard#
Visit /analytics/login and enter your ANALYTICS_SECRET to authenticate. The dashboard uses httpOnly cookie authentication, so your secret is never exposed in URLs or browser history.
Every page view from that point forward is tracked automatically.
Cost#
Everything in this system is free:
- Neon Postgres: Free tier includes 0.5 GB storage and 190 hours of compute per month
- Vercel Serverless Functions: Free tier includes 100 GB-hours per month
- Vercel Web Analytics + Speed Insights: Free tier included with all Vercel plans
- IP intelligence APIs: ip-api.com (45 req/min), RDAP (free), Nominatim (1 req/sec), all free, no keys
- No external analytics services: No Google Analytics, no Mixpanel, no monthly SaaS fees
For a portfolio site, this will likely be free forever.
The Layered Approach#
The final system has three layers of analytics, each serving a different purpose:
| Layer | Tool | What It Shows | Blind Spot |
|---|---|---|---|
| Aggregate traffic | Vercel Web Analytics | Page views, referrers, top pages | Blocked by ad blockers |
| Performance | Vercel Speed Insights | Core Web Vitals, LCP, CLS | Blocked by ad blockers |
| Per-visitor detail | Custom Neon tracking | IP, geo, device, browser, referrer | None (server-side) |
| Security intel | IP OSINT panel | ISP, org, proxy flags, WHOIS | On-demand only |
The combination gives you complete visibility. Vercel's tools handle the aggregate view for visitors who aren't running ad blockers. The custom system catches everyone and adds the per-visitor detail that lets you actually analyze traffic patterns and spot anomalies.
Written by Chris Johnson and edited by Claude Code (Opus 4.6). The full source code is at github.com/chris2ao/cryptoflexllc. This post is part of a series about AI-assisted development. Previous: My First 24 Hours with Claude Code. Next: Security Hardening a Custom Analytics Dashboard.
Weekly Digest
Get a weekly email with what I learned, summaries of new posts, and direct links. No spam, unsubscribe anytime.
Related Posts
How I built a subscriber-gated comment system with thumbs up/down reactions, admin moderation, and a one-time welcome email blast, including the PowerShell quirks and Vercel WAF rules that nearly blocked everything.
How I built a full newsletter system for this site with secure subscriptions, HMAC-verified unsubscribes, branded HTML emails, and a Vercel Cron that sends a weekly digest every Monday. Includes the WAF rule that broke everything and the firewall tightening that followed.
How a basic page-view tracker evolved into a 9-section, 26-component analytics command center with heatmaps, scroll depth tracking, bot detection, and API telemetry. Includes the reasoning behind every upgrade and enough puns to make a data scientist groan.
Comments
Subscribers only — enter your subscriber email to comment
