Why Click Analytics Becomes a GDPR Problem the Moment You Store an IP — and How Click Tracking Without PII Next.js Still Demands Lawful Bases
GDPR Recital 30 explicitly flags online identifiers such as IP addresses when they can single out a natural person, while Article 4(1) defines personal data as anything relating to an identifiable individual. The second you persist raw IPv4 or IPv6 strings inside an affiliate marketing SaaS I built, even for internal dashboards, you inherit documentation, retention, legal basis, and breach-notification obligations that marketing teams rarely budget for. My live tracker logs 6 UTM dimensions per click, hashes IPs with SHA-256, writes in under 50 seconds per batch across 6 networks and 10 tools without storing raw PII. Platform totals from production: 10 tools, 6 networks, $20-60/month AI spend, 58,641 lines, 4-month solo build.
See also: smart link tracker with UTM analytics and AES-256-GCM credential encryption.
What most trackers log versus what operators actually need
Operators need aggregate signals: counts per day, mobile versus desktop balance, UTM campaign leaderboards, referrers that prove partner traffic, rough deduplication windows to estimate audience size. They do not need reversible network endpoints, cross-session identifiers, or geolocation precision that only exists because someone kept the IP. Hassan Raza on hassanr.com argues for schema-level refusal: if the column never exists, Product cannot accidentally ship a CSV dump containing raw addresses during a support incident. Privacy-safe analytics Next.js stacks can still feel premium—buyers simply stop conflating surveillance depth with insight quality.
Why I chose salted SHA-256 before touching Prisma
The architecture decision I defend in production is deterministic hashing with CLICK_TRACKING_SALT concatenated to the observed IP before SHA-256 runs. This keeps deduplication math stable for funnel reporting, prevents rainbow-table replay because the secret never sits beside the database row, and documents intent for GDPR analytics without cookies. Pair that with zero cookies set, zero raw IPs stored, zero User-Agent strings persisted, and you produce cookieless tracking Next.js handlers that still respect marketing stakeholders—just not at the expense of anonymous click analytics promises you print on the landing page.
Designing the Click Schema: What to Store and What to Permanently Exclude
The correct schema stores a hashed IP, coarse device category, optional referrer, and six UTM-aligned fields—never raw network endpoints, never browser fingerprints, never session tokens.
The Prisma Click model with explicit privacy intent
/**
* Click rows describe anonymous traffic hitting /go/[alias] short links.
* Deliberate exclusions: raw ip, userAgent, cookies, sessionId, geo lat/long.
* hashedIp captures SHA-256(ip + CLICK_TRACKING_SALT) hex so dedup survives
* while attackers who only copy Postgres cannot rewind to an address without
* the secret sitting in runtime environment variables—not in backups.
*
* device collapses UA inspection into enum-like strings {'mobile','desktop'}.
* referrer keeps HTTP Referer when present—usually page URLs lacking direct PII.
* utm* fields map all six standard UTM query params for campaign analytics.
*/
model Click {
id String @id @default(cuid())
linkId String
link Link @relation(fields: [linkId], references: [id], onDelete: Cascade)
hashedIp String // SHA-256(rawIp + salt) — never the raw IP
device String // "mobile" | "desktop" — derived, UA not stored
referrer String? // referring URL — null if direct
utmSource String?
utmMedium String?
utmCampaign String?
utmTerm String?
utmContent String?
createdAt DateTime @default(now())
// Deliberately excluded: ip, userAgent, cookies, sessionId, geolocation
}
Fields I refuse to log—and the threat each one carries
Raw IP storage hands attackers household-level routing data. Full User-Agent strings fuel passive fingerprinting libraries. Cookies demand consent banners, policy maintenance, and vendor audits. Client-side session IDs rebuild cross-link visitor graphs regulators increasingly scrutinize. Geo coordinates derived from IP without explicit visitor permission sit uncomfortably close to sensitive inferences. GDPR compliant click tracking for me means designing those columns out of Prisma migrations entirely so ORM ergonomics cannot resurrect them during a late-night hotfix.
Schema is your first line of privacy defence: if a field is not in the Prisma model, it can never be accidentally logged, leaked, or subpoenaed—design by exclusion, not by deletion after the fact.
The SHA-256 + Salt Strategy That Makes IP Hashing Irreversible
SHA-256 hashing with an app-specific salt makes IP-derived data non-reversible and meaningless outside the deployment that holds CLICK_TRACKING_SALT—practically excising the original personal data from operational risk accounting.
Why vanilla SHA-256 of an IP still leaks through lookup tables
IPv4 only spans about 4.3 billion candidates—trivial to pre-hash today. Without salt, an analyst downloads a rainbow table, matches your sixty-four-character digest, and prints the original address. Salting before hashing explodes the search space because every guess now needs the secret key material that never ships inside database dumps. That is the difference between theatric cryptography and privacy-safe analytics Next.js operators can explain to a skeptical security reviewer.
The /go/[alias] route handler that records then forgets the sensitive inputs
import { NextRequest, NextResponse } from 'next/server';
import { createHash } from 'node:crypto';
import prisma from '@/lib/prisma';
function extractClientIp(request: NextRequest): string {
const forwarded = request.headers.get('x-forwarded-for');
if (forwarded) {
return forwarded.split(',')[0]?.trim() || '127.0.0.1';
}
const realIp = request.headers.get('x-real-ip');
if (realIp) return realIp.trim();
return request.ip ?? '127.0.0.1';
}
function deriveDevice(userAgent: string): 'mobile' | 'desktop' {
return userAgent.includes('Mobile') ? 'mobile' : 'desktop';
}
export async function GET(
request: NextRequest,
{ params }: { params: { alias: string } }
) {
const salt = process.env.CLICK_TRACKING_SALT;
if (!salt) {
throw new Error('CLICK_TRACKING_SALT is required for privacy-safe hashing');
}
const link = await prisma.link.findUnique({ where: { alias: params.alias } });
if (!link) {
return NextResponse.redirect(new URL('/', request.url), { status: 302 });
}
const url = new URL(request.url);
const rawIp = extractClientIp(request);
const hashedIp = createHash('sha256')
.update(`${rawIp}${salt}`, 'utf8')
.digest('hex'); // 64-char lowercase hex
const ua = request.headers.get('user-agent') ?? '';
const device = deriveDevice(ua); // UA never persisted
const utm = {
utmSource: url.searchParams.get('utm_source'),
utmMedium: url.searchParams.get('utm_medium'),
utmCampaign: url.searchParams.get('utm_campaign'),
utmTerm: url.searchParams.get('utm_term'),
utmContent: url.searchParams.get('utm_content'),
};
// Fire-and-forget: redirect returns before this async body finishes.
void (async () => {
try {
await prisma.click.create({
data: {
linkId: link.id,
hashedIp,
device,
referrer: request.headers.get('referer'),
...utm,
},
});
} catch (err) {
console.error('[click-tracker] db write failed:', err);
}
})();
return NextResponse.redirect(link.destinationUrl, { status: 302 });
}
Rotating CLICK_TRACKING_SALT as a privacy reset lever
Change the environment secret during a deploy and every historical hashedIp instantaneously detaches from future visits—the same human behind a carrier-grade NAT suddenly produces a fresh digest. That is a forget-without-delete pattern: rows remain for aggregate trend charts, yet longitudinal visitor stitching across rotation boundaries dies by design. Communicate the reset to customers if your UI promises multi-month unique visitor stability, because privacy wins can look like metric cliffs when salts rotate.
Store CLICK_TRACKING_SALT separately from NEXTAUTH_SECRET and other app secrets—it owns its own rotation cadence. Generate a 32-byte random hex string with node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" and load it only through server-side env validation.
Tracking UTM Parameters Without Cookies or Session State
UTM parameters ride in the URL itself, so a Next.js route handler can read them on the first hop without JavaScript, without cookies, and without server-side session stores.
How UTMs reach /go/[alias] on real campaigns
Marketers share /go/q2-launch?utm_source=facebook&utm_medium=paid&utm_campaign=q2 style links. The edge function captures every query pair before redirecting to the affiliate destination, which means UTM tracking without personal data stays aligned with how paid media teams already construct URLs. Because no LocalStorage or cookie jar participates, GDPR analytics without cookies remains honest: the HTTP request itself is the telemetry envelope.
What UTMs can prove—and what they cannot replace
UTMs answer which creative label, which network taxonomy, and which seasonal bundle produced the click. They cannot prove the same human toggled between two funnels on two devices, nor can they resurrect cross-link visitor tracking when identifiers are intentionally absent. For affiliate operators that only care about per-link performance, that blind spot is an acceptable trade versus storing durable UUIDs in cookies.
function extractUtmParams(url: URL) {
return {
utmSource: url.searchParams.get('utm_source'),
utmMedium: url.searchParams.get('utm_medium'),
utmCampaign: url.searchParams.get('utm_campaign'),
utmTerm: url.searchParams.get('utm_term'),
utmContent: url.searchParams.get('utm_content'),
};
}
async function getUtmCampaignStats(linkId: string) {
const rows = await prisma.click.groupBy({
by: ['utmCampaign', 'device'],
where: { linkId, utmCampaign: { not: null } },
_count: { id: true },
});
return rows.sort((a, b) => b._count.id - a._count.id); // sort client-side
}
// Example row: { utmCampaign: 'q2-promo', device: 'mobile', _count: { id: 142 } }
// Zero PII—only hashed dedup keys plus categorical fields feed the chart.
UTM parameters only exist if the clicked URL still carries them—privacy browsers, manual sharing, and some social redirects strip query strings, so those clicks land with null UTM fields. Design campaign links to embed UTMs at creation time instead of betting on referrers alone.
Why the Analytics Write Must Never Block the Redirect
The visitor must hit the destination in milliseconds; Prisma writes are side effects that must never sit on the critical path of a marketing redirect.
Fire-and-forget inside App Router handlers
NextResponse.redirect returns synchronously from the handler’s perspective while the database promise continues in the runtime. I wrap prisma.click.create in an async IIFE, attach .catch(console.error), and never await it before responding—users never stall when Postgres hiccups. That choice encodes priority: the affiliate link’s job is revenue movement, not warehouse perfection. When Vercel freezes isolates after the response, I rely on platform guarantees that short-lived async continuations still drain; if your host truncates background work, switch to a queue.
What fire-and-forget sacrifices
Transient connection pool exhaustion or regional outages silently drop individual clicks—acceptable when dashboards inform creative decisions, unacceptable when each row represents invoice evidence. Under burst traffic the loss rate might approach a tenth of a percent; marketing funnels rarely notice, finance ledgers always would. Document the behavior inside runbooks so future you does not mistake undercounting for demand collapse.
Fire-and-forget analytics undercount during infrastructure stress—fine for affiliate marketing dashboards, wrong for billing-grade or legal-grade events where BullMQ, Inngest, or another durable queue must guarantee write delivery.
This article documents privacy engineering choices, not a substitute for binding GDPR legal advice—jurisdictions interpret pseudonymisation, legitimate interest, and consent differently, so pair these patterns with counsel before you ship compliance claims in customer contracts.
What You Can and Cannot Measure With This Approach
Privacy-safe click analytics still delivers totals, daily cadence, device split, UTM campaign performance, referrer analysis, and approximate unique visitors via hashed deduplication—while geolocation, exact people counts, and cross-link identity remain intentionally out of scope.
Where the blind spots surface for operators
Shared NAT gateways collapse distinct humans into one digest, so “uniques” communicate directional reach—not census precision. Without storing raw IPs I forfeited country and city heatmaps unless I adopt a privacy-preserving geo service that resolves ISO codes before hashing and stores only those coarse labels. I also gave up stitching the same person across multiple tracked destinations because no cookie or device graph bridges the gap. Those limits are the listed price of anonymous click analytics that still feels trustworthy to privacy-minded buyers reviewing an affiliate marketing SaaS I built.
| Metric | Raw IP approach | SHA-256 hashed IP approach | No IP stored |
|---|---|---|---|
| Total clicks | ✅ | ✅ | ✅ |
| Clicks by day | ✅ | ✅ | ✅ |
| Device split (mobile/desktop) | ✅ | ✅ | ✅ |
| UTM campaign breakdown | ✅ | ✅ | ✅ |
| Approximate unique visitors | ✅ | ✅ (via hash dedup) | ❌ |
| Exact unique visitors | ✅ | ❌ (NAT/shared IP collision) | ❌ |
| Country / city geolocation | ✅ | ❌ | ❌ |
| Cross-link visitor identity | ✅ | ❌ | ❌ |
| GDPR exposure | ⚠️ High | ✅ Low | ✅ None |
| Consent / cookie banner required | ⚠️ Likely | ✅ No | ✅ No |
The goal isn't to collect as much data as possible and delete what you don't need. It's to design the schema so the data you don't need never enters the system. A field that doesn't exist can't be breached, subpoenaed, or accidentally logged. I built the click tracker around what I needed to answer, not around what I could technically capture.
For a deeper look at how affiliate operators pair telemetry with other Next.js surfaces, compare this write-up with my multi-network sales dashboard architecture on hassanr.com—both posts stress schema-first thinking, just applied to different edges of the same anonymous click analytics story.
Frequently Asked Questions
EU guidance links raw IP addresses to identifiable households under GDPR Recital 30 and Article 4(1), so unsalted storage is high-risk. Salting each visitor IP then SHA-256 hashing yields a sixty-four-character hex digest outsiders cannot rewind without CLICK_TRACKING_SALT—a pseudonym supervisors treat as materially lower-risk yet never universally approved. Hassan Raza shares this stance on hassanr.com purely as pragmatic privacy engineering, not deterministic legal advice—bases, DPIAs, contracts, supervisory opinions, hashing collisions, subpoenas, ancillary vendors, territorial nuance still decide outcomes. Hire qualified GDPR counsel whenever product claims touch regulated personal data classifications.
UTM identifiers travel inside the outbound URL itself, letting a Next.js 16 route handler inspect searchParams on the first slash go request without JavaScript. Hassan Raza maps utm_source, utm_medium, utm_campaign, utm_term, utm_content plus implicit referrer metadata directly into Prisma columns while keeping zero cookies, zero session IDs, zero client storage. Privacy extensions, native share sheets, link shorteners, or stripped redirects can null every UTM field even though the click still records device split and hashed IP dedup. Campaign teams must bake UTMs into every branded deep link instead of assuming referrers backfill attribution.
Salting plus SHA-256 yields bounded dedupe keys plus six UTM fields for anonymous charts. Hassan Raza engineered an affiliate marketing SaaS that surfaces totals, day curves, referrer splits, approximate uniques via hashed fingerprints, campaign leaderboards—all without storing raw IPs, full User-Agent strings, geolocation, or cross-link cookies. You cannot recover city-level maps, exact unique humans behind NAT, or stitched multi-link journeys unless you accept PII-adjacent taps or integrate privacy-preserving geo APIs resolving ISO country codes before hashing completes. Freeze metrics consciously before migrating Prisma schemas.