Skip to content
~sami-ullah

Concept project · runs in your browserTypeScript · React.js · Next.js

URL Shortener with Click Analytics Demo

Paste a long URL and get a short code, or claim a custom alias. Press “visit” to follow a link and watch the request log show the 301 redirect, the click being counted and the referrer chart updating. Fast-forward the clock to see an expiring link return 410 Gone.

Read the case study →

Interactive demo

samiullah.tech/lab/url-shortener
Your short links
shorttargetclicksActions
clock: +0dFast-forward to see expiring links return 410 Gone.

stats

Select a link to see its stats.

request log

  1. $ waiting for requests…

## How it works

Every new link gets an auto-incrementing id. The id is scrambled with a multiplicative hash modulo a prime, then base62-encoded, so codes are short, never collide and do not reveal how many links exist.

Custom aliases go through the same table with a unique index on lower(code), so “Launch” and “launch” cannot both exist. A clash comes back as 409 Conflict; reserved words such as api or admin are rejected up front.

Expired links answer 410 Gone instead of 404, which tells search engines the link was removed on purpose. Stats are grouped per hour and per referrer, the same aggregation the SQL stats endpoint would run.

## View source

The code running in the window above, exactly as deployed.

app/lab/url-shortener/shortener.ts110 lines
/**
 * Core logic of the URL shortener, kept free of React so it reads like the server module it would be.
 * In production this runs in an Express.js handler backed by PostgreSQL; here it runs in the browser.
 */

const ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";

/** Base62-encode a positive integer (the row id), so codes stay short and unique without collisions. */
export function base62(n: number): string {
  if (!Number.isSafeInteger(n) || n < 0) throw new RangeError("id must be a non-negative integer");
  let out = "";
  do {
    out = ALPHABET[n % 62] + out;
    n = Math.floor(n / 62);
  } while (n > 0);
  return out;
}

/**
 * Scramble sequential ids before encoding so codes are not guessable (1 -> "1", 2 -> "2"...).
 * A multiplicative inverse modulo a prime keeps the mapping reversible and collision-free.
 */
const PRIME = 2_147_483_647; // 2^31 - 1
const MULT = 48_271;
export const scramble = (id: number) => Number((BigInt(id) * BigInt(MULT)) % BigInt(PRIME));

export const RESERVED = new Set(["api", "admin", "login", "r", "lab", "static"]);
const ALIAS_RE = /^[a-zA-Z0-9][a-zA-Z0-9-]{2,29}$/;

export type Link = {
  id: number;
  code: string;
  target: string;
  createdAt: number;
  expiresAt: number | null;
  clicks: { at: number; referrer: string }[];
};

export type CreateInput = { target: string; alias?: string; ttlDays?: number | null };

export type Result<T> = { ok: true; value: T } | { ok: false; error: string };

/** Validate and normalise the destination URL. Only http(s); no links back to the shortener itself. */
export function normaliseTarget(raw: string, ownHost: string): Result<string> {
  let url: URL;
  try {
    url = new URL(/^https?:\/\//i.test(raw.trim()) ? raw.trim() : `https://${raw.trim()}`);
  } catch {
    return { ok: false, error: "That is not a valid URL." };
  }
  if (!["http:", "https:"].includes(url.protocol)) return { ok: false, error: "Only http and https links are allowed." };
  if (!url.hostname.includes(".")) return { ok: false, error: "The URL needs a full domain, such as example.com." };
  if (url.hostname === ownHost) return { ok: false, error: "Short links cannot point at the shortener itself (redirect loops)." };
  return { ok: true, value: url.toString() };
}

export function createLink(links: Link[], input: CreateInput, ownHost: string, now = Date.now()): Result<Link> {
  const target = normaliseTarget(input.target, ownHost);
  if (!target.ok) return target;

  const id = (links.at(-1)?.id ?? 1000) + 1;
  let code: string;
  if (input.alias) {
    if (!ALIAS_RE.test(input.alias)) return { ok: false, error: "Alias: 3–30 letters, digits or dashes, starting with a letter or digit." };
    if (RESERVED.has(input.alias.toLowerCase())) return { ok: false, error: `"${input.alias}" is reserved.` };
    // In PostgreSQL this is a UNIQUE index on lower(code); the insert fails with 23505 and we map it to 409.
    if (links.some((l) => l.code.toLowerCase() === input.alias!.toLowerCase())) {
      return { ok: false, error: `409 Conflict: "${input.alias}" is already taken.` };
    }
    code = input.alias;
  } else {
    code = base62(scramble(id));
  }

  return {
    ok: true,
    value: {
      id,
      code,
      target: target.value,
      createdAt: now,
      expiresAt: input.ttlDays ? now + input.ttlDays * 86_400_000 : null,
      clicks: [],
    },
  };
}

export type Resolution =
  | { status: 301; location: string; link: Link }
  | { status: 404 }
  | { status: 410; link: Link };

/** What GET /r/:code returns. 410 Gone for expired links so crawlers drop them. */
export function resolve(links: Link[], code: string, now = Date.now()): Resolution {
  const link = links.find((l) => l.code === code);
  if (!link) return { status: 404 };
  if (link.expiresAt !== null && link.expiresAt <= now) return { status: 410, link };
  return { status: 301, location: link.target, link };
}

/** Clicks per hour for the last `hours` hours, oldest first (what the stats endpoint aggregates in SQL). */
export function clicksByHour(link: Link, hours = 12, now = Date.now()): number[] {
  const out = Array.from({ length: hours }, () => 0);
  for (const c of link.clicks) {
    const ago = Math.floor((now - c.at) / 3_600_000);
    if (ago >= 0 && ago < hours) out[hours - 1 - ago]! += 1;
  }
  return out;
}
app/lab/url-shortener/Demo.tsx286 lines
"use client";

import { useEffect, useMemo, useState } from "react";
import { clicksByHour, createLink, resolve, type Link } from "./shortener";

const KEY = "lab:url-shortener:v1";
const HOST = "sami.link";
const REFERRERS = ["linkedin.com", "x.com", "direct", "news.ycombinator.com", "google.com"];

type LogLine = { at: number; text: string; tone: "ok" | "warn" | "err" | "info" };

function seed(now: number): Link[] {
  const mk = (id: number, code: string, target: string, clicks: number): Link => ({
    id,
    code,
    target,
    createdAt: now - 5 * 3_600_000,
    expiresAt: null,
    clicks: Array.from({ length: clicks }, (_, i) => ({
      at: now - ((i * 7919) % (11 * 3_600_000)),
      referrer: REFERRERS[i % REFERRERS.length]!,
    })),
  });
  return [
    mk(1001, "nextjs-docs", "https://nextjs.org/docs", 23),
    mk(1002, "pg-index", "https://www.postgresql.org/docs/current/indexes.html", 9),
  ];
}

function Sparkline({ values }: { values: number[] }) {
  const max = Math.max(1, ...values);
  const w = 240;
  const h = 48;
  const pts = values.map((v, i) => `${(i / (values.length - 1)) * w},${h - (v / max) * (h - 4) - 2}`).join(" ");
  return (
    <svg viewBox={`0 0 ${w} ${h}`} className="h-12 w-full" role="img" aria-label={`Clicks per hour, last ${values.length} hours: ${values.join(", ")}`}>
      <polyline points={`0,${h} ${pts} ${w},${h}`} fill="var(--accent)" fillOpacity={0.12} stroke="none" />
      <polyline points={pts} fill="none" stroke="var(--accent)" strokeWidth={2} strokeLinejoin="round" />
    </svg>
  );
}

export default function Demo() {
  const [links, setLinks] = useState<Link[]>([]);
  const [target, setTarget] = useState("");
  const [alias, setAlias] = useState("");
  const [ttl, setTtl] = useState<string>("");
  const [error, setError] = useState<string | null>(null);
  const [selected, setSelected] = useState<string | null>(null);
  const [log, setLog] = useState<LogLine[]>([]);
  const [offsetDays, setOffsetDays] = useState(0);
  const now = Date.now() + offsetDays * 86_400_000;

  // Load from localStorage after mount (the server render has no storage).
  useEffect(() => {
    let initial: Link[] | null = null;
    try {
      const raw = localStorage.getItem(KEY);
      if (raw) initial = JSON.parse(raw) as Link[];
    } catch {
      /* storage blocked or corrupted: start from the seed */
    }
    const list = initial?.length ? initial : seed(Date.now());
    setLinks(list);
    setSelected(list[0]?.code ?? null);
  }, []);

  useEffect(() => {
    if (!links.length) return;
    try {
      localStorage.setItem(KEY, JSON.stringify(links));
    } catch {
      /* ignore */
    }
  }, [links]);

  const push = (text: string, tone: LogLine["tone"]) =>
    setLog((l) => [{ at: Date.now(), text, tone }, ...l].slice(0, 30));

  function onCreate(e: React.FormEvent) {
    e.preventDefault();
    const res = createLink(links, { target, alias: alias || undefined, ttlDays: ttl ? Number(ttl) : null }, HOST, now);
    push(`POST /api/links ${JSON.stringify({ target, alias: alias || undefined })}`, "info");
    if (!res.ok) {
      setError(res.error);
      push(res.error.startsWith("409") ? res.error : `422 Unprocessable Entity: ${res.error}`, "err");
      return;
    }
    setError(null);
    setLinks((l) => [...l, res.value]);
    setSelected(res.value.code);
    setTarget("");
    setAlias("");
    push(`201 Created → https://${HOST}/${res.value.code}`, "ok");
  }

  function visit(code: string) {
    const referrer = REFERRERS[Math.floor(Math.random() * REFERRERS.length)]!;
    const r = resolve(links, code, now);
    push(`GET /${code}  (referrer: ${referrer})`, "info");
    if (r.status === 301) {
      setLinks((ls) => ls.map((l) => (l.code === code ? { ...l, clicks: [...l.clicks, { at: now, referrer }] } : l)));
      push(`301 Moved Permanently  Location: ${r.location}`, "ok");
    } else if (r.status === 410) {
      push(`410 Gone: link expired`, "warn");
    } else {
      push(`404 Not Found`, "err");
    }
  }

  function remove(code: string) {
    setLinks((ls) => ls.filter((l) => l.code !== code));
    push(`DELETE /api/links/${code} → 204 No Content`, "ok");
    if (selected === code) setSelected(null);
  }

  function reset() {
    const s = seed(Date.now());
    setLinks(s);
    setSelected(s[0]!.code);
    setLog([]);
    setOffsetDays(0);
  }

  const current = links.find((l) => l.code === selected) ?? null;
  const series = useMemo(() => (current ? clicksByHour(current, 12, now) : []), [current, now]);
  const refs = useMemo(() => {
    const m = new Map<string, number>();
    current?.clicks.forEach((c) => m.set(c.referrer, (m.get(c.referrer) ?? 0) + 1));
    return [...m.entries()].sort((a, b) => b[1] - a[1]);
  }, [current]);

  // The log is always a dark terminal, so it uses fixed colours rather than theme tokens.
  const tone = { ok: "text-[#4ade80]", warn: "text-[#fbbf24]", err: "text-[#f87171]", info: "text-[#a1a1aa]" } as const;

  return (
    <div className="grid gap-0 lg:grid-cols-[1.4fr_1fr]">
      <div className="space-y-6 p-4 sm:p-6">
        <form onSubmit={onCreate} className="space-y-3" noValidate>
          <label className="block">
            <span className="text-sm font-medium">Long URL</span>
            <input
              value={target}
              onChange={(e) => setTarget(e.target.value)}
              placeholder="https://example.com/a/very/long/path?utm_source=newsletter"
              className="mt-1 w-full rounded-md border border-border bg-bg-elev px-3 py-2 font-mono text-sm outline-none focus:border-accent"
              aria-invalid={!!error}
              aria-describedby={error ? "short-error" : undefined}
              required
            />
          </label>
          <div className="grid gap-3 sm:grid-cols-[1fr_10rem_auto]">
            <label className="block">
              <span className="text-sm font-medium">Custom alias (optional)</span>
              <div className="mt-1 flex rounded-md border border-border bg-bg-elev focus-within:border-accent">
                <span className="px-3 py-2 font-mono text-sm text-fg-muted">{HOST}/</span>
                <input
                  value={alias}
                  onChange={(e) => setAlias(e.target.value)}
                  placeholder="launch"
                  className="w-full bg-transparent py-2 pr-3 font-mono text-sm outline-none"
                />
              </div>
            </label>
            <label className="block">
              <span className="text-sm font-medium">Expires</span>
              <select
                value={ttl}
                onChange={(e) => setTtl(e.target.value)}
                className="mt-1 w-full rounded-md border border-border bg-bg-elev px-3 py-2 text-sm"
              >
                <option value="">Never</option>
                <option value="1">In 1 day</option>
                <option value="7">In 7 days</option>
              </select>
            </label>
            <button type="submit" className="self-end rounded-md bg-accent px-4 py-2 font-medium text-accent-fg hover:opacity-90">
              Shorten
            </button>
          </div>
          {error && (
            <p id="short-error" role="alert" className="text-sm text-danger">
              {error}
            </p>
          )}
        </form>

        <div className="overflow-x-auto">
          <table className="w-full text-left text-sm">
            <caption className="sr-only">Your short links</caption>
            <thead className="font-mono text-xs text-fg-muted">
              <tr className="border-b border-border">
                <th className="py-2 pr-3 font-normal">short</th>
                <th className="py-2 pr-3 font-normal">target</th>
                <th className="py-2 pr-3 text-right font-normal">clicks</th>
                <th className="py-2 font-normal">
                  <span className="sr-only">Actions</span>
                </th>
              </tr>
            </thead>
            <tbody>
              {links.map((l) => {
                const expired = l.expiresAt !== null && l.expiresAt <= now;
                return (
                  <tr
                    key={l.code}
                    className={`border-b border-border ${selected === l.code ? "bg-accent-soft/60" : ""}`}
                  >
                    <td className="py-2 pr-3 font-mono">
                      <button type="button" onClick={() => setSelected(l.code)} className="hover:text-accent">
                        /{l.code}
                      </button>
                      {expired && <span className="ml-2 text-xs text-warn-fg">expired</span>}
                    </td>
                    <td className="max-w-[16rem] truncate py-2 pr-3 text-fg-muted" title={l.target}>
                      {l.target.replace(/^https?:\/\//, "")}
                    </td>
                    <td className="py-2 pr-3 text-right font-mono">{l.clicks.length}</td>
                    <td className="py-2 text-right whitespace-nowrap">
                      <button type="button" onClick={() => visit(l.code)} className="rounded border border-border px-2 py-0.5 font-mono text-xs hover:border-accent hover:text-accent">
                        visit
                      </button>{" "}
                      <button type="button" onClick={() => remove(l.code)} aria-label={`Delete /${l.code}`} className="rounded border border-border px-2 py-0.5 font-mono text-xs hover:border-danger hover:text-danger">
                        ×
                      </button>
                    </td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>

        <div className="flex flex-wrap items-center gap-3 text-xs text-fg-muted">
          <span className="font-mono">clock: +{offsetDays}d</span>
          <button type="button" onClick={() => setOffsetDays((d) => d + 1)} className="rounded border border-border px-2 py-1 font-mono hover:border-accent">
            fast-forward 1 day
          </button>
          <button type="button" onClick={reset} className="rounded border border-border px-2 py-1 font-mono hover:border-accent">
            reset demo
          </button>
          <span>Fast-forward to see expiring links return 410 Gone.</span>
        </div>
      </div>

      <div className="space-y-6 border-t border-border bg-bg-elev p-4 sm:p-6 lg:border-t-0 lg:border-l">
        <section aria-label="Link stats">
          <h3 className="font-mono text-sm font-semibold">
            stats {current ? <span className="text-accent">/{current.code}</span> : null}
          </h3>
          {current ? (
            <>
              <p className="mt-1 text-xs text-fg-muted">Clicks per hour, last 12 hours</p>
              <Sparkline values={series} />
              <ul className="mt-3 space-y-1 text-sm">
                {refs.map(([r, n]) => (
                  <li key={r} className="flex items-center gap-2">
                    <span className="w-40 truncate font-mono text-xs">{r}</span>
                    <span className="h-2 rounded bg-accent-2/70" style={{ width: `${(n / current.clicks.length) * 100}%` }} />
                    <span className="font-mono text-xs text-fg-muted">{n}</span>
                  </li>
                ))}
                {!refs.length && <li className="text-xs text-fg-muted">No clicks yet. Press “visit”.</li>}
              </ul>
            </>
          ) : (
            <p className="mt-2 text-sm text-fg-muted">Select a link to see its stats.</p>
          )}
        </section>

        <section aria-label="Request log">
          <h3 className="font-mono text-sm font-semibold">request log</h3>
          <ol role="log" aria-live="polite" className="mt-2 h-56 overflow-y-auto rounded-md border border-border bg-[#0b0b0d] p-3 font-mono text-[12px] leading-relaxed">
            {log.length === 0 && <li className="text-[#71717a]">$ waiting for requests…</li>}
            {log.map((l, i) => (
              <li key={l.at + "-" + i} className={tone[l.tone]}>
                {l.text}
              </li>
            ))}
          </ol>
        </section>
      </div>
    </div>
  );
}