Rukon
Full-Stack Development4 min read

First-Party Analytics Without Cookies: A Daily-Rotating Salted IP Hash

Page views and link clicks recorded by the site itself, with no third-party tracker and no cookies. A salted IP hash that changes daily, beacons that survive navigation, and what is still stored.

By

AnalyticsPrivacyNext.jsSupabasesendBeaconVercel

The problem: knowing what people tap, without a tracker

rukon-link is the page behind my personal QR code, live at link.rukon.dev: social links, a link to my portfolio, and contact details released only on request. I wanted to know how many people open it, where they come from and which links they tap. I did not want a third-party analytics script, a cookie banner, or a table of IP addresses. This article is for anyone building small first-party analytics in Next.js and wanting to be precise about what it does and does not store.

The pieces are a tiny client component that sends events, one API route that validates and stores them, and a Supabase PostgreSQL table that browsers cannot read or write directly.

What is stored, and what is not

Being honest here matters more than the implementation, so this comes first.

Stored per eventNot stored
Event type (page view, link click, contact request) and link keyThe raw IP address
Path, referrer and a derived source such as a UTM tag or referring siteAny cookie
User agent, and the device type, browser and OS parsed from itPrecise location or any browser permission
Country and city from the hosting edge's headersAnything sent to a third party
A daily-rotating salted hash of the IP
A random visitor id and session id from browser storage, when available

The last row is the one to be clear about. "No cookies" is true, but the page does keep identifiers in the browser: a random UUID in localStorage as rl_vid, used to count unique visitors across days, and another in sessionStorage as rl_sid, used to count visits. They are random, contain nothing about the person, and are only read by this site, but they are persistent identifiers on the visitor's device, and the site's privacy page says so: "a random anonymous ID stored in your browser, not a cookie". In private mode or with storage blocked, the code catches the error and sends no id, and events are still counted, just not de-duplicated.

The daily-rotating salted IP hash

The server still needs some notion of "the same network" for two jobs: rate limiting, and counting visitors who have no stored id. It derives a hash and never keeps the address:

export function ipHash(req: Request) {
  const day = new Date().toISOString().slice(0, 10);
  return createHash("sha256")
    .update(`${clientIp(req)}|${env.analyticsSalt ?? "dev-only-salt"}|${day}`)
    .digest("hex");
}
tsFrom lib/server/request-info.ts. The client IP is the first entry of x-forwarded-for, used only to compute this.

Including the UTC date means the same address produces a different hash every day, so hashes cannot be linked across days. The secret salt, held only in a server environment variable, stops anyone with just the database from recomputing hashes for guessed addresses. It is worth being precise about the limit of that: whoever holds the salt could, for a given day, test candidate addresses against a stored hash, because the address space is small. The protection is that the salt and the data are kept apart, not that the hash is mathematically irreversible.

Sending events that survive navigation

A link click is recorded at the moment the visitor leaves the page, which is exactly when an ordinary fetch can be cancelled. The tracker uses navigator.sendBeacon, which the browser completes after unload, and falls back to fetch with keepalive: true if the beacon is unavailable or refused:

const body = JSON.stringify({ ...event, ...analyticsContext() });
const blob = new Blob([body], { type: "application/json" });
if (!navigator.sendBeacon?.("/api/track", blob)) {
  fetch("/api/track", { method: "POST", body, headers: { "content-type": "application/json" }, keepalive: true }).catch(() => {});
}
tsFrom components/Tracker.tsx.

Clicks are captured with one listener on the document in the capture phase, for any element marked data-track="<link key>". It also listens for auxclick and keeps middle-button clicks, because opening a link in a new tab is still a tap on that link. One page view is sent per load.

The server side: refuse quietly, store little

  1. Requests whose Sec-Fetch-Site or Origin header shows another site get an empty 204, so a probe learns nothing. A client that sends neither header, such as a script, is not stopped by this check, which is why the next ones exist.
  2. Only two event types are accepted, and a link click must name a key from the site's own list of trackable links.
  3. Bot traffic is dropped by user-agent pattern: crawlers, link previewers such as WhatsApp and Telegram, headless browsers and command-line clients.
  4. A rate limit of 60 events per minute per IP hash.
  5. Text fields are trimmed, stripped of control characters and length-capped, and the table's CHECK constraints cap them again.

Country and city come from the hosting edge's x-vercel-ip-country and x-vercel-ip-city headers, so there is no geolocation lookup and no permission prompt; the country code is turned into a name with Intl.DisplayNames. These headers only exist once deployed, so local development shows no locations.

Two limitations are stated on the project page. The analytics rate limit is an in-memory counter per server instance, so it blunts floods rather than enforcing a global limit. And user-agent bot filtering only catches clients that identify themselves.

Where the data goes next

The dashboard turns these rows into totals for a chosen date range in the site owner's time zone, with one SQL function, and old events are rolled up into daily totals and deleted after a retention period, 90 days by default. Time zones matter here as much as they did in The Business Day Is Not the Server Day. The whole project is described on the rukon-link project page.