Why Build Your Own Link Tracker Instead of Using Bitly — and When to Build Link Tracker Next.js UTM Analytics In-House
Third-party shorteners tax every click, silo attribution inside someone else’s dashboard, and force affiliates to trust unfamiliar domains—building your own branded /go/ path keeps data first-party, per-click costs at zero, and conversion context inside the same SaaS where campaigns live. Platform totals from production: 10 tools, 6 networks, $20-60/month AI spend, 58,641 lines, 4-month solo build.
See also: affiliate sales dashboard aggregation.
Bitly solves shortening, not integrated affiliate workflows
Marketers juggling Facebook ads, email drips, and YouTube descriptions already reconcile numbers across three portals. Exporting Bitly CSVs into spreadsheets adds friction—and enterprise tiers charge per seat once funnels scale. An affiliate marketing SaaS I built embeds link tracking beside sales dashboards so operators create spring-sale aliases, watch click spikes, and update manual conversion rows without tab hopping.
Branded /go/[alias] URLs signal trust
yourdomain.com/go/webinar-2024 reads intentional; bit.ly/x7Kp2Q reads suspicious in regulated niches. Hassan Raza on hassanr.com documents how first-party redirects pair with privacy-safe click logging patterns—same Postgres tenant, same SHA-256 salted deduplication philosophy, different product surface aimed at marketers instead of compliance auditors.
This architecture spans alias generation with collision suffixes, HTTP 302 redirects, asynchronous Click inserts, dual UTM storage, and a Recharts dashboard plotting thirty-day slopes plus five manual funnel metrics operators paste from CRMs and affiliate portals.
Link creation runs through a Zod-validated Server Action: marketers supply a name, destinationUrl, platform enum, optional alias, and five UTM fields. When someone skips the alias, I slugify Spring Sale! into spring-sale; when spring-sale already exists, the collision loop tries spring-sale-2, spring-sale-3, and so on until Postgres accepts the insert. The returned short URL always lives on your domain—yourdomain.com/go/spring-sale—not a third-party redirector that could change pricing or sunset API access mid-campaign.
The /go/[alias] Public Redirect Route Architecture
A public Next.js App Router GET handler at /go/[alias] resolves globally unique aliases, kicks off click logging without awaiting Postgres, and issues NextResponse.redirect within roughly twenty to fifty milliseconds.
Why dynamic [alias] segments belong in route.ts
App Router dynamic params keep marketing slugs readable—spring-sale beats opaque base62 tokens—and let Server Actions validate uniqueness before inserts. Public routes skip auth because ad clicks arrive anonymous; security lives in alias entropy plus rate limits, not session cookies.
Lookup → log → redirect without blocking the visitor
The handler loads Link metadata, merges canonical UTMs into destinationUrl, fires logClick through void so TypeScript acknowledges intentional non-awaiting, then returns status 302 before Prisma finishes writing Click rows.
import { db } from '@/lib/db'
import { logClick } from '@/features/link-tracker/services/click-logger'
import { NextRequest, NextResponse } from 'next/server'
import { notFound } from 'next/navigation'
/**
* Public redirect: resolves /go/[alias], logs click asynchronously, redirects fast.
* void logClick — redirect must not wait on Postgres write latency.
* 302 — temporary redirect so browsers do not cache stale destinations forever.
*/
export async function GET(
request: NextRequest,
{ params }: { params: { alias: string } }
) {
const { alias } = params
const link = await db.link.findUnique({
where: { alias },
select: {
id: true,
destinationUrl: true,
utmSource: true,
utmMedium: true,
utmCampaign: true,
utmTerm: true,
utmContent: true,
},
})
if (!link) return notFound()
const destination = new URL(link.destinationUrl)
if (link.utmSource) destination.searchParams.set('utm_source', link.utmSource)
if (link.utmMedium) destination.searchParams.set('utm_medium', link.utmMedium)
if (link.utmCampaign) destination.searchParams.set('utm_campaign', link.utmCampaign)
if (link.utmTerm) destination.searchParams.set('utm_term', link.utmTerm)
if (link.utmContent) destination.searchParams.set('utm_content', link.utmContent)
void logClick(link.id, request)
return NextResponse.redirect(destination.toString(), 302)
}
Use HTTP 302 temporary redirects, not 301 permanent ones—browsers cache 301 targets aggressively, so changing destinationUrl later leaves stale users stranded on old landing pages until cache expiry.
Privacy-Safe Click Logging with Hashed IPs and Device Detection
Every click persists a SHA-256 salted IP digest for deduplication, coarse mobile versus desktop labels inferred from User-Agent regex, referrer headers, and UTM pairs scraped from inbound query strings—never raw network endpoints.
Why raw IPs stay out of Postgres
Regulators treat IPs as identifiable in many contexts; hashing with IP_HASH_SALT yields sixty-four-character hex fingerprints useful for approximate unique counts without reversible PII. The same pattern powers cookieless analytics I documented separately—here it feeds link-level funnels instead of sitewide telemetry.
Click rows capture visit-level context
Foreign keys tie events to Link.id, timestamps anchor thirty-day charts, deviceType enums stay intentionally coarse, and try/catch wrappers ensure logging failures never surface as 500 errors after redirects already succeeded.
IP extraction deserves its own paragraph because deployment targets disagree on headers. Vercel forwards x-forwarded-for; Cloudflare prefers cf-connecting-ip; local dev falls back to connection metadata. My MVP hardcodes the Vercel path—split on comma, take the first hop, trim whitespace—because that matches where the platform ships. Production hardening checks headers in priority order and treats unknown as a literal string so hashing still produces stable digests instead of crashing mid-redirect.
import { db } from '@/lib/db'
import crypto from 'crypto'
import { NextRequest } from 'next/server'
export async function logClick(linkId: string, request: NextRequest) {
try {
const forwardedFor = request.headers.get('x-forwarded-for')
const ip = forwardedFor ? forwardedFor.split(',')[0].trim() : 'unknown'
const userAgent = request.headers.get('user-agent') || ''
const referrer = request.headers.get('referer') || null
const { searchParams } = new URL(request.url)
const utmSource = searchParams.get('utm_source') || null
const utmMedium = searchParams.get('utm_medium') || null
const utmCampaign = searchParams.get('utm_campaign') || null
const utmTerm = searchParams.get('utm_term') || null
const utmContent = searchParams.get('utm_content') || null
const hashedIp = crypto
.createHash('sha256')
.update(ip + process.env.IP_HASH_SALT)
.digest('hex')
const mobileRegex = /Mobile|Android|iPhone|iPad|iPod|BlackBerry|Windows Phone/i
const deviceType = mobileRegex.test(userAgent) ? 'mobile' : 'desktop'
await db.click.create({
data: {
linkId,
hashedIp,
deviceType,
referrer,
utmSource,
utmMedium,
utmCampaign,
utmTerm,
utmContent,
timestamp: new Date(),
},
})
} catch (error) {
console.error('[Click Logging Error]', error)
}
}
void logClick(...) swallows failures silently once redirects return—if Postgres hiccups during a launch spike, clicks vanish unless you add Redis or SQS retry queues. Console errors suffice for MVP funnels; production-grade analytics products need durable ingestion.
How UTM Parameter Tracking Works (Creation → Redirect → Analytics)
UTM parameters can bake into Link rows at creation and still accept dynamic overrides on shared /go/ URLs—storing both canonical defaults and per-click observations unlocks creative A/B tests without minting duplicate aliases.
Link table stores marketer-configured defaults
When someone creates Spring Sale Ad pointing at example.com/sale with utm_source=facebook and utm_medium=cpc, those strings persist as structured columns—not buried inside destinationUrl blobs—so dashboards render campaign names without URL parsing gymnastics.
Redirect time merges defaults into destination queries
The route handler constructs URL objects, sets searchParams from Link fields, and redirects prospects to fully tagged landing pages even if they only clicked the bare /go/spring-sale slug.
Click logging captures query overrides on the /go/ link itself
Sharing /go/spring-sale?utm_content=variant-a versus variant-b on the same alias lets Click rows record which creative drove traffic while Link defaults remain unchanged—ideal for newsletter split tests.
Concrete campaign math clarifies why dual storage matters. A Facebook ad might report 1,000 impressions (views), the link tracker counts 120 automatic clicks, a landing page captures 30 email signups (leads), and an affiliate network confirms 8 sales worth $450 revenue. Operators paste those five Conversion numbers manually; the dashboard calculates 25% lead rate from clicks and $3.75 revenue per click without pretending pixels exist on every downstream hop.
Keep utm_source, utm_medium, utm_campaign, utm_term, and utm_content lowercase—Google Analytics treats casing inconsistently, and mixed-case params fracture channel reports across platforms.
The Database Schema: Links, Clicks, and Conversion Metrics
Three Prisma models—Link for aliases, Click for events, Conversion for manual funnel numbers—deliver end-to-end tracking from creation through ROI math without bolting on external pixels for MVP scope.
Link enforces globally unique aliases scoped by userId
Collision loops append -2, -3 suffixes when promo is taken; platform enums label acquisition channels; optional UTM columns stay nullable for organic links.
Click indexes on (linkId, timestamp) keep analytics snappy
Thirty-day timelines filter timestamp gte thirtyDaysAgo—composite indexes prevent sequential scans when affiliates accumulate tens of thousands of visits.
Conversion stores five manual metrics per link
views, leads, sales, revenue, and calls update through Server Actions after operators copy numbers from ad managers, landing page tools, and affiliate dashboards—pragmatic when automatic pixels cannot span every hop.
Automatic conversion tracking would require webhooks from Facebook, pixels on landing pages, and API polling against six affiliate networks—weeks of integration work for numbers marketers already copy weekly. The Conversion model accepts imperfection: one row per linkId with @unique constraint, updatedAt tracking when someone last reconciled figures, and nullable defaults at zero so new links do not break funnels before the first campaign ends.
model Link {
id String @id @default(cuid())
userId String
name String // Internal label (e.g., "Spring Sale Promo")
alias String @unique // Globally unique (e.g., "spring-sale")
destinationUrl String
platform Platform
utmSource String?
utmMedium String?
utmCampaign String?
utmTerm String?
utmContent String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
clicks Click[]
conversion Conversion?
@@index([userId])
}
model Click {
id String @id @default(cuid())
linkId String
hashedIp String
deviceType DeviceType
referrer String?
utmSource String?
utmMedium String?
utmCampaign String?
utmTerm String?
utmContent String?
timestamp DateTime @default(now())
link Link @relation(fields: [linkId], references: [id], onDelete: Cascade)
@@index([linkId, timestamp])
}
model Conversion {
id String @id @default(cuid())
linkId String @unique
views Int @default(0)
leads Int @default(0)
sales Int @default(0)
revenue Float @default(0)
calls Int @default(0)
updatedAt DateTime @updatedAt
link Link @relation(fields: [linkId], references: [id], onDelete: Cascade)
}
enum Platform {
FACEBOOK
INSTAGRAM
EMAIL
YOUTUBE
TWITTER
TIKTOK
OTHER
}
enum DeviceType {
MOBILE
DESKTOP
}
The Analytics Dashboard: Click Timeline, Device Breakdown, and UTM Insights
The /link-tracker dashboard aggregates Click rows with Prisma groupBy, renders thirty-day Recharts timelines, compares mobile versus desktop share, surfaces referrer leaders, and overlays Conversion funnels when operators log manual metrics—all filtered by session.user.id.
Click timeline buckets need JavaScript day truncation
Prisma groupBy on raw timestamps returns row-level granularity; I fetch recent Click.timestamp values, bucket into UTC dates in application code, then feed Recharts line charts showing daily totals for the trailing thirty days.
Device breakdown queries stay simpler: await db.click.groupBy({ by: ['deviceType'], where: { linkId }, _count: true }) returns mobile versus desktop counts in one round trip. Referrer tables group null referrers into direct traffic, strip query strings for readability, and sort by _count descending so operators spot which newsletter edition drove Tuesday’s spike without exporting CSVs.
Device and referrer splits expose creative fatigue early
groupBy deviceType with _count highlights when mobile CTR dominates; grouping referrer strings (null mapped to direct) shows which newsletters or social posts spike—pair with UTM campaign tables comparing utmCampaign frequency against Link defaults.
Unique versus total clicks use distinct hashedIp
COUNT(*) totals every visit; COUNT(DISTINCT hashedIp) approximates unique visitors behind carrier NAT—good enough for affiliate funnels, wrong for cross-device identity. When Conversion rows exist, calculated rates display leads divided by clicks (25% example) and revenue per click ($3.75 when $450 revenue meets 120 clicks).
Every query includes where: { link: { userId: session.user.id } } so multi-tenant isolation stays enforced at the data layer—not merely hidden in UI routes.
Why I Chose Fire-and-Forget Logging (And What It Costs You)
Awaiting click inserts before redirecting adds two hundred to three hundred milliseconds users feel immediately; void logging preserves twenty to fifty millisecond hops but sacrifices guaranteed write durability when Postgres stumbles.
MVP affiliate features tolerate best-effort counts
When the product is the redirect—not Bitly-grade analytics perfection—losing a fraction of clicks under load beats teaching prospects your links feel sluggish. I log errors loudly and plan Redis-backed retry buffers before pitching enterprise SLAs.
Three production gaps I document openly: failed void writes vanish unless retried, distinct hashedIp overcounts when one person clicks from phone and laptop, and x-forwarded-for alone misidentifies IPs behind certain proxies. Each has a known fix—SQS dead-letter queues, session cookies for same-domain conversions, header priority chains—but none blocked shipping the feature inside an affiliate SaaS where speed and first-party branding mattered more than audit-grade telemetry.
| Blocking (Await) Logging | Fire-and-Forget (Void) Logging | |
|---|---|---|
| Redirect latency | 200-300ms (user-visible delay) | 20-50ms (instant redirect) |
| Click logging reliability | ✅ Guaranteed (transaction) | ⚠️ Best-effort (may fail silently) |
| Error handling | Can return error to user | Errors logged, redirect succeeds |
| Production readiness | Good for low-traffic | Needs retry queue (Redis/SQS) |
| User experience | Slower | Faster |
| Implementation complexity | Low (simple await) | Medium (need error logging) |
The fire-and-forget pattern is a bet that fast redirects matter more than perfect click data. For affiliate marketing where the click is the product, that bet pays off. For analytics products where the data is the product, it doesn't. Know which one you're building.
Future upgrades I would ship: expiresAt on Link rows for campaign sunsets, automatic conversion pixels for same-domain signups, and header priority chains beyond x-forwarded-for when deploying off Vercel. Until then, this stack ships fast inside the platform without pretending to be Bitly.
Frequently Asked Questions
Expose a /go/[alias] App Router GET handler that resolves branded aliases from Postgres before redirecting. Hassan Raza ships this inside affiliate marketing SaaS tooling on hassanr.com: Prisma Link rows map spring-sale style slugs to destinationUrl targets, void logClick(request) writes SHA-256 salted IP hashes plus device and referrer metadata without delaying NextResponse.redirect(destination, 302). Fire-and-forget logging keeps latency near twenty milliseconds while click rows capture UTMs scraped from searchParams. Gate creation through authenticated Server Actions scoped by userId so tenant isolation never leaks foreign funnels.
Reuse the /go/[alias] redirect stack, then aggregate Click rows inside a tenant-scoped dashboard. Hassan Raza powers timelines with Prisma groupBy buckets feeding Recharts thirty-day slopes, deviceType splits contrasting mobile against desktop, distinct hashedIp counts approximating unique visitors, and Conversion models tracking five manual funnel metrics—views, leads, sales, revenue, calls. URL shortener analytics stay first-party: alias generation handles promo-2 collision suffixes, platform badges label Facebook versus Email campaigns, referrer tables highlight which newsletters drove spikes. Marketers reconcile CRM numbers manually while click telemetry stays automatic.
Canonical UTMs live on Link rows; each redirect appends them while Click rows capture query overrides. Hassan Raza stores utm_source, utm_medium, utm_campaign, utm_term, utm_content lowercase during creation, merges them into destinationUrl at redirect time, yet still logs /go/spring-sale?utm_content=variant-a payloads separately for creative testing. Link table holds defaults marketers expect; Click table preserves actual inbound parameters when partners append experiments without duplicating aliases. Five-parameter hygiene prevents Analytics casing drift. Dual storage unlocks A/B labels on one branded slug while dashboards compare canonical versus observed attribution side by side.