Terminal.skills
Use Cases/Build a URL Shortener with Analytics

Build a URL Shortener with Analytics

Build a URL shortener with custom slugs, click analytics, geographic tracking, device detection, QR code generation, link expiration, and A/B testing for marketing campaigns.

#redis#caching#database#pub-sub#queues
Works with:claude-codeopenai-codexgemini-clicursor
$

The Problem

Jana leads marketing at a 25-person SaaS. They share links on social media, email campaigns, and partner sites — but have zero visibility into what happens after. They don't know which channels drive traffic, what devices users click from, or which geographic regions convert best. They use Bitly ($348/year) but need custom branded domains, deeper analytics, and A/B link testing. They need a self-hosted URL shortener with rich analytics.

Step 1: Build the URL Shortener

typescript
// src/links/shortener.ts — URL shortener with analytics and A/B testing
import { randomBytes } from "node:crypto";
import { pool } from "../db";
import { Redis } from "ioredis";

const redis = new Redis(process.env.REDIS_URL!);

const SLUG_LENGTH = 6;
const DEFAULT_DOMAIN = process.env.SHORT_DOMAIN || "link.example.com";

interface ShortLink {
  id: string;
  slug: string;
  originalUrl: string;
  domain: string;
  title: string | null;
  tags: string[];
  expiresAt: string | null;
  password: string | null;
  maxClicks: number | null;
  abTargets: Array<{ url: string; weight: number }> | null;
  clickCount: number;
  createdBy: string;
  createdAt: string;
}

interface ClickEvent {
  linkId: string;
  ip: string;
  country: string;
  city: string;
  device: string;
  browser: string;
  os: string;
  referrer: string;
  timestamp: number;
}

// Create short link
export async function createLink(
  originalUrl: string,
  options?: {
    slug?: string;
    domain?: string;
    title?: string;
    tags?: string[];
    expiresAt?: string;
    password?: string;
    maxClicks?: number;
    abTargets?: Array<{ url: string; weight: number }>;
    userId?: string;
  }
): Promise<{ shortUrl: string; link: ShortLink }> {
  const slug = options?.slug || generateSlug();
  const domain = options?.domain || DEFAULT_DOMAIN;

  // Check slug availability
  const existing = await redis.get(`link:${domain}:${slug}`);
  if (existing) throw new Error("Slug already taken");

  const id = `lnk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;

  await pool.query(
    `INSERT INTO short_links (id, slug, domain, original_url, title, tags, expires_at, password, max_clicks, ab_targets, created_by, created_at)
     VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW())`,
    [id, slug, domain, originalUrl, options?.title, JSON.stringify(options?.tags || []),
     options?.expiresAt, options?.password, options?.maxClicks,
     options?.abTargets ? JSON.stringify(options.abTargets) : null,
     options?.userId]
  );

  // Cache for instant redirect
  await redis.set(`link:${domain}:${slug}`, JSON.stringify({
    id, url: originalUrl, expiresAt: options?.expiresAt,
    password: options?.password, maxClicks: options?.maxClicks,
    abTargets: options?.abTargets,
  }));

  return {
    shortUrl: `https://${domain}/${slug}`,
    link: {
      id, slug, originalUrl, domain, title: options?.title || null,
      tags: options?.tags || [], expiresAt: options?.expiresAt || null,
      password: options?.password || null, maxClicks: options?.maxClicks || null,
      abTargets: options?.abTargets || null, clickCount: 0,
      createdBy: options?.userId || "", createdAt: new Date().toISOString(),
    },
  };
}

// Resolve and redirect
export async function resolveLink(domain: string, slug: string, clickData: Partial<ClickEvent>): Promise<{
  url: string | null;
  expired: boolean;
  passwordRequired: boolean;
}> {
  const cached = await redis.get(`link:${domain}:${slug}`);
  if (!cached) return { url: null, expired: false, passwordRequired: false };

  const link = JSON.parse(cached);

  // Check expiration
  if (link.expiresAt && new Date(link.expiresAt) < new Date()) {
    return { url: null, expired: true, passwordRequired: false };
  }

  // Check max clicks
  if (link.maxClicks) {
    const clicks = parseInt(await redis.get(`clicks:count:${link.id}`) || "0");
    if (clicks >= link.maxClicks) {
      return { url: null, expired: true, passwordRequired: false };
    }
  }

  // Password check
  if (link.password) {
    return { url: null, expired: false, passwordRequired: true };
  }

  // A/B testing: weighted random selection
  let targetUrl = link.url;
  if (link.abTargets?.length > 0) {
    targetUrl = selectABTarget(link.abTargets);
  }

  // Track click (non-blocking)
  trackClick(link.id, targetUrl, clickData).catch(() => {});

  return { url: targetUrl, expired: false, passwordRequired: false };
}

// A/B target selection
function selectABTarget(targets: Array<{ url: string; weight: number }>): string {
  const totalWeight = targets.reduce((s, t) => s + t.weight, 0);
  let random = Math.random() * totalWeight;

  for (const target of targets) {
    random -= target.weight;
    if (random <= 0) return target.url;
  }

  return targets[0].url;
}

// Track click analytics
async function trackClick(linkId: string, targetUrl: string, data: Partial<ClickEvent>): Promise<void> {
  const now = Date.now();
  const day = new Date(now).toISOString().slice(0, 10);
  const hour = new Date(now).toISOString().slice(0, 13);

  const pipe = redis.pipeline();

  // Increment counters
  pipe.incr(`clicks:count:${linkId}`);
  pipe.hincrby(`clicks:daily:${linkId}`, day, 1);
  pipe.hincrby(`clicks:hourly:${linkId}`, hour, 1);
  pipe.expire(`clicks:hourly:${linkId}`, 86400 * 7);

  // Track by dimension
  if (data.country) pipe.hincrby(`clicks:country:${linkId}`, data.country, 1);
  if (data.device) pipe.hincrby(`clicks:device:${linkId}`, data.device, 1);
  if (data.browser) pipe.hincrby(`clicks:browser:${linkId}`, data.browser, 1);
  if (data.referrer) {
    const refDomain = extractDomain(data.referrer);
    pipe.hincrby(`clicks:referrer:${linkId}`, refDomain, 1);
  }

  // A/B tracking
  if (targetUrl) {
    pipe.hincrby(`clicks:ab:${linkId}`, targetUrl, 1);
  }

  await pipe.exec();

  // Persist to DB (batched)
  await pool.query(
    `INSERT INTO click_events (link_id, target_url, ip_hash, country, city, device, browser, os, referrer, clicked_at)
     VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())`,
    [linkId, targetUrl, data.ip ? simpleHash(data.ip) : null,
     data.country, data.city, data.device, data.browser, data.os, data.referrer]
  );
}

// Get link analytics
export async function getAnalytics(linkId: string): Promise<{
  totalClicks: number;
  clicksByDay: Record<string, number>;
  clicksByCountry: Record<string, number>;
  clicksByDevice: Record<string, number>;
  clicksByBrowser: Record<string, number>;
  clicksByReferrer: Record<string, number>;
  abResults: Record<string, number> | null;
}> {
  const [total, daily, country, device, browser, referrer, ab] = await Promise.all([
    redis.get(`clicks:count:${linkId}`),
    redis.hgetall(`clicks:daily:${linkId}`),
    redis.hgetall(`clicks:country:${linkId}`),
    redis.hgetall(`clicks:device:${linkId}`),
    redis.hgetall(`clicks:browser:${linkId}`),
    redis.hgetall(`clicks:referrer:${linkId}`),
    redis.hgetall(`clicks:ab:${linkId}`),
  ]);

  return {
    totalClicks: parseInt(total || "0"),
    clicksByDay: Object.fromEntries(Object.entries(daily).map(([k, v]) => [k, parseInt(v)])),
    clicksByCountry: Object.fromEntries(Object.entries(country).map(([k, v]) => [k, parseInt(v)])),
    clicksByDevice: Object.fromEntries(Object.entries(device).map(([k, v]) => [k, parseInt(v)])),
    clicksByBrowser: Object.fromEntries(Object.entries(browser).map(([k, v]) => [k, parseInt(v)])),
    clicksByReferrer: Object.fromEntries(Object.entries(referrer).map(([k, v]) => [k, parseInt(v)])),
    abResults: Object.keys(ab).length > 0 ? Object.fromEntries(Object.entries(ab).map(([k, v]) => [k, parseInt(v)])) : null,
  };
}

function generateSlug(): string {
  const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
  return Array.from(randomBytes(SLUG_LENGTH)).map((b) => chars[b % chars.length]).join("");
}

function extractDomain(url: string): string {
  try { return new URL(url).hostname; } catch { return "direct"; }
}

function simpleHash(s: string): string {
  let h = 0;
  for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
  return h.toString(36);
}

Results

  • Channel attribution solved — each campaign gets its own short link; analytics show Twitter drives 3x more clicks than LinkedIn; marketing budget reallocated accordingly
  • A/B link testing increased conversions 23% — two landing page variants split 50/50; winning variant rolled out to 100% after 1,000 clicks
  • Custom branded domain — links.acme.com instead of bit.ly; brand recognition in every shared link
  • Geographic insights — 60% of clicks from US, 15% from UK; team created UK-specific landing page → UK conversion rate doubled
  • $348/year Bitly cost eliminated — self-hosted with richer analytics and custom features; ROI from A/B testing alone exceeded the development cost in month 1