How to Build a Smart Link Tracker with UTM Analytics in Next.js

I built a link tracker that logs every click, captures UTMs, records device and referrer, and maps conversions—all behind a /go/[alias] public redirect. The redirect lands in about twenty milliseconds; click logging finishes in the background. If you need to build link tracker Next.js UTM analytics without Bitly invoices or third-party domains, this post walks alias creation, HTTP 302 redirects, privacy-safe SHA-256 IP hashing, and the dashboard that turns raw Click rows into thirty-day timelines marketers actually trust.

How to Build a Smart Link Tracker with UTM Analytics in Next.js
/go/[alias] public redirect architecture with fire-and-forget click logging, privacy-safe IP hashing, and UTM analytics dashboard

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)
}
Tip

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)
  }
}
Warning

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.

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.

Important

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.

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.

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.