ZyVOP Logo
Content That Connects
SeriesAI NewsWhy ZyVOPJoin Discord
LoginGet Started
ZyVOP Logo
Content That Connects

The Developer Publishing Hub. Write once, cross-post to Dev.to, Medium, Hashnode, WordPress & Bluesky with automated canonical source tags and zero paywalls.

Content

  • Categories
  • Tags
  • Badges
  • Leaderboard
  • Write Article
  • Newsletter

Company

  • About Us
  • Why ZyVOP
  • Developer API & CLI
  • Write for Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Developer Publishing Hub.

Zero paywalls · Full content ownership
All systems operational
HomeTutorialBuild a URL Shortener With Click Analytics in Node.js
Tutorial

Build a URL Shortener With Click Analytics in Node.js

Short code generation, SQLite click tracking, IP geolocation, referrer analytics, and a live dashboard — no external services, no infrastructure, one Node.js process.

ZyVOP
ZyVOP
Senior Developer
July 15, 2026
7 min read
Build a URL Shortener With Click Analytics in Node.js
#IP geolocation#open-source#security#nodejs#url shortener#SQLite click tracking
👍1

Bit.ly and short.io are fine until you want to know which country your clicks are coming from, which referrer is driving traffic, and whether that campaign from last Tuesday is still converting. At that point, you're either paying for a premium plan or wishing you'd just built it yourself.

This post builds a complete URL shortener with analytics — short code generation, SQLite storage, IP geolocation, referrer tracking, and a dashboard showing daily click trends, browser breakdown, and country distribution.

No external analytics service, no database server to spin up. The whole thing runs on SQLite and ships as a single Node.js process.

Source code: https://github.com/zyvop27-cmyk/zyvop-blogs/tree/main/url-shortener


Stack and setup

Express 5 · SQLite (better-sqlite3) · geoip-lite · ua-parser-js · nanoid
git clone [YOUR_GITHUB_REPO_URL]
cd url-shortener
npm install
npm start

Open http://localhost:3000. The database creates itself in data/links.db on first run.

One environment variable is required before clicks will be tracked — HASH_SALT, used to hash IP addresses before storing them. Generate one:

node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

Add it to .env. Without it, the server starts but logs a warning and skips click recording on every redirect.


Short code generation

The first decision: how to make the codes. Random is the right choice over sequential integers — sequential IDs are trivially enumerable, someone can increment from 1 and hit every link ever created.

Random codes with a good alphabet make that infeasible.

The alphabet deliberately excludes characters that look alike:

// src/lib/shortcode.js
import { customAlphabet } from "nanoid";

const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
const CODE_LENGTH = 7;
const generate = customAlphabet(ALPHABET, CODE_LENGTH);

export function generateCode(db, existsFn, maxAttempts = 5) {
  for (let i = 0; i < maxAttempts; i++) {
    const code = generate();
    if (!existsFn(db, code)) return code;
  }
  throw new Error("Failed to generate a unique code after multiple attempts");
}

No 0, O, I, i, l, o, or 1 — seven characters that look like each other on printed paper or low-res screens. With 55 characters and 7 positions that's 55^7 ≈ 1.52 trillion possible codes. Collision handling is implemented correctly anyway (retry up to 5 times), but with that address space and any realistic link volume, it will never be needed.


The database

SQLite handles everything: URL storage, click records, and all the analytics queries. No Postgres, no Redis, no infrastructure to run. better-sqlite3 is synchronous, which means no connection pool to manage and no async/await ceremony around queries.

Two tables:

CREATE TABLE links (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  code       TEXT NOT NULL UNIQUE,
  url        TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);

CREATE TABLE clicks (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  link_id    INTEGER NOT NULL REFERENCES links(id),
  clicked_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
  country    TEXT,
  referrer   TEXT,
  browser    TEXT,
  os         TEXT,
  ip_hash    TEXT
);

The analytics queries (top countries, referrers, daily trend) all run against the clicks table with a JOIN on links. The most complex one is the daily trend:

db.prepare(`
  SELECT
    strftime('%Y-%m-%d', clicked_at) AS date,
    COUNT(*) AS clicks
  FROM clicks WHERE link_id = ?
  GROUP BY date ORDER BY date DESC LIMIT 30
`).all(link.id);

SQLite's strftime handles the date bucketing natively. No date library needed.

One gotcha with the database module: better-sqlite3 is synchronous and stateful, so the module caches the connection after the first call. Tests need isolated in-memory databases, not the cached production one. The fix is simple — never cache :memory: connections:

export function getDb(dbPath = DB_PATH) {
  if (dbPath !== ":memory:" && _db) return _db;
  const db = new Database(dbPath);
  // ... schema setup ...
  if (dbPath !== ":memory:") _db = db;
  return db;
}

Each test gets its own fresh in-memory database. Nothing bleeds between test cases.


Recording clicks without slowing down redirects

The redirect handler records the click and then sends the 301. better-sqlite3 is synchronous — it blocks the Node.js event loop during the write — but WAL mode means it's appending to the write-ahead log file rather than syncing the main database.

A single INSERT completes in microseconds in practice. The try/catch ensures analytics failures never break a redirect:

// src/routes/redirect.js
const CODE_RE = /^[A-Za-z0-9]{4,10}$/;

router.get("/:code", (req, res) => {
  const { code } = req.params;
  if (!CODE_RE.test(code)) return res.status(404).send("Link not found.");

  const link = getLinkByCode(db, code);
  if (!link) return res.status(404).send("Link not found.");

  try {
    const clickData = parseClickData(req);
    insertClick(db, link.id, clickData);
  } catch (err) {
    console.error("[redirect] analytics error:", err.message);
  }

  res.redirect(301, link.url);
});

The /:code route must be registered last in server.js. It matches any single-segment path (including /healthz), so any named routes registered after it will never be reached. Express matches in registration order, not specificity order.

One other decision worth noting: 301 vs 302. A 301 (permanent redirect) tells browsers and search engines to cache the destination — follow the link once and the browser may never hit the shortener again.

That means if you later change the destination URL, existing users won't see the change. For a personal shortener that's usually fine; use 302 if you need updatable destinations.

The POST /api/shorten endpoint is rate-limited to 20 requests per hour per IP. Redirect and stats endpoints are not limited — a popular link shouldn't throttle its own clicks.


What gets tracked and how

Three pieces of data come in on every click: the IP address, the User-Agent header, and the Referer header.

// src/lib/analytics.js
export function parseClickData(req) {
  const ip = getClientIp(req);
  const ua = req.headers["user-agent"] ?? "";
  const rawReferrer = req.headers["referer"] || req.headers["referrer"] || null;

  const salt = process.env.HASH_SALT;
  if (!salt) throw new Error("HASH_SALT is not set. Add it to your .env file.");
  const ipHash = crypto.createHash("sha256").update(ip + salt).digest("hex").slice(0, 16);
  const geo = ip ? geoip.lookup(ip) : null;
  const country = geo?.country ?? null;

  const parsed = UAParser(ua);
  const browser = parsed.browser?.name ?? null;
  const os = parsed.os?.name ?? null;

  let referrer = null;
  if (rawReferrer) {
    try {
      referrer = new URL(rawReferrer).hostname;
    } catch {
      referrer = rawReferrer.slice(0, 100);
    }
  }

  return { country, referrer, browser, os, ipHash };
}

The IP gets hashed before storage. Raw IPs are PII — storing them without justification is a compliance headache in most jurisdictions.

A SHA-256 hash of the IP (keyed with a secret from your environment) lets you count unique visitors reliably without keeping the raw address. geoip-lite does the country lookup from a bundled local database, so there's no external API call.

Referrers get normalized to hostname only. https://google.com/search?q=example becomes google.com. The full URL isn't useful for the dashboard, and storing full search queries you didn't ask for is the kind of thing that causes problems later.


The dashboard

The dashboard is a single HTML file that calls the API. No framework:

  • GET /api/links — all shortened links with total click counts

  • GET /api/stats/:code — full breakdown for one link (by country, referrer, browser, daily trend)

The daily trend renders as a bar chart built from raw div elements. Bar height is a percentage of the maximum day's count, so no chart library dependency.

Clicking "Stats" on any row loads that link's analytics inline below the table. The stat panel shows total clicks, unique visitors (by hashed IP), and three bar charts. Country shows the top 10 countries, referrer shows where traffic came from, browser breaks down the client distribution.


Trying it with curl

Make sure HASH_SALT is set in .env before running these — without it the server starts and redirects work, but clicks won't be recorded and stats will show zero.

Shorten a URL:

curl -X POST http://localhost:3000/api/shorten \
  -H "Content-Type: application/json" \
  -d '{"url": "https://zyvop.com/building-a-production-ai-agent-in-node-js-tool-calling-the-react-loop-and-error-handling-zzftm}'
{
  "code": "P8WbGd7",
  "shortUrl": "http://localhost:3000/P8WbGd7",
  "url": "https://zyvop.com/building-a-production-ai-agent-in-nodejs"
}

Follow the redirect:

curl -I http://localhost:3000/P8WbGd7
HTTP/1.1 301 Moved Permanently
Location: https://zyvop.com/building-a-production-ai-agent-in-nodejs

Pull the stats:

curl http://localhost:3000/api/stats/P8WbGd7
{
  "link": { "code": "P8WbGd7", "url": "https://zyvop.com/...", "created_at": "2026-07-14T06:00:00Z" },
  "totalClicks": 3,
  "uniqueVisitors": 1,
  "byCountry": [{ "country": "US", "clicks": 3 }],
  "byReferrer": [{ "referrer": "google.com", "clicks": 2 }, { "referrer": "Direct", "clicks": 1 }],
  "byBrowser": [{ "browser": "Chrome", "clicks": 3 }],
  "dailyTrend": [{ "date": "2026-07-14", "clicks": 3 }]
}

"Direct" in byReferrer is what COALESCE(referrer, 'Direct') returns for clicks that arrived without a Referer header — typed directly into the browser bar, opened from a native app, or clicked from an email client that strips referrers.

Invalid URL:

curl -s -w " [%{http_code}]" -X POST http://localhost:3000/api/shorten \
  -H "Content-Type: application/json" \
  -d '{"url": "ftp://notallowed"}'
{"error":"url must be a valid http or https URL."} [400]

Unknown code:

curl -I http://localhost:3000/ZZZZZZZ
HTTP/1.1 404 Not Found

Tests

npm test
# tests 24
# pass  24
# fail   0

24 tests across three suites. The shortcode suite verifies the alphabet, the collision retry logic, and that it throws correctly when retries are exhausted.

The analytics suite tests IP hashing, country lookup, referrer normalization, and user-agent parsing — including the privacy property that raw IPs don't appear in the stored hash. The database suite runs every query against an in-memory SQLite instance: inserts, retrieval, unique visitor counting, daily trend grouping, and the unique constraint on codes.


Taking it further

The setup above works for personal use or a small team. A few things to add before opening it to the public:

  • Auth on the shorten endpoint — right now anyone who can reach the server can create links. A simple API key check is enough for most cases.

  • Custom slugs — let users specify POST /api/shorten with an optional customCode field; validate it against the same alphabet and check for conflicts before inserting.

  • Click buffering — if redirect volume gets high, the synchronous SQLite write on every click becomes the bottleneck. Buffer clicks in memory and flush in batches of 100 or every 5 seconds.

  • Link expiry — add an expires_at column to links and check it in the redirect handler.

Get the code: https://github.com/zyvop27-cmyk/zyvop-blogs/tree/main/url-shortener

Comments (0)

Login to post a comment.

ZyVOP
ZyVOP

Founder of Zyvop 🚀 | Building AI-driven tools & premium insights for software engineers, CTOs, and tech leaders. Obsessed with automating workflows and exploring the frontier of AI.

Subscribe to ZyVOP's Newsletter
Build a URL Shortener With Click Analytics in Node.js

More from ZyVOP

View profile

Debian Adopts "Responsible Use of Generative AI" After Nine-Way Condorcet Vote

Debian's General Resolution 2026-002 closed on August 28 with "Responsible Use of Generative AI" beating eight rival proposals, including a Social Contract ban, by a clear Condorcet margin, per the project secretary's published beat matrix.

3 minAug 30

How I Built a Real-Time Developer Trend Radar Into My SEO Growth Engine

An AI-powered content intelligence system that streams live developer conversations from Hacker News, Dev.to, Google Search, and GitHub — and turns them into ready-to-write blog opportunities with one click.

12 minAug 29

Qwen3.8-Flash-Next Cost Efficiency, OpenExecutive Satire, and Multi-Vector Retrieval Advances

This week's digest covers Qwen3.8-Flash-Next's push for ultimate cost-efficiency, the viral OpenExecutive project, and the technical release of MultiVectorEncoder in Sentence-Transformers v6.0.

4 minAug 28

Anthropic’s Pricing Shock, Granite 4.2 Open‑Source Leap, and AI‑Powered Security & Policy Shifts

From Anthropic’s flagship model losing steam to IBM’s 512 K‑token Granite 4.2, plus a new wave of AI‑driven security exploits and policy alarms, this week’s digest maps the technical and market forces you need to act on now.

3 minAug 26

Introducing Questions and Discussions: A New Way to Connect!

We are thrilled to announce a major update to how you can interact and share content on our platform! Up until now, sharing your thoughts meant writing a standa...

2 minAug 1