ZyVOP Logo
Content That Connects
SeriesAI NewsCategoriesTags
ZyVOP Logo
Content That Connects

Empowering developers and creators with cutting-edge insights, comprehensive tutorials, and innovative solutions for the digital future.

Content

  • Tags
  • Write Article
  • Newsletter

Company

  • About Us
  • Contact

Connect

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

© 2026 ZyVOP. Crafted with care for the developer community.

Made with ❤️ by the ZyVOP team
All systems operational
HomeTutorialBuild a URL Shortener With Click Analytics in Node.js
Tutorial
👍1

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.

#nodejs#url shortener#open-source#security#IP geolocation#SQLite click tracking
Z
ZyVOP

Senior Developer

July 15, 2026
7 min read
0 views
Build a URL Shortener With Click Analytics in Node.js

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

Z

ZyVOP

Passionate developer sharing knowledge about modern web technologies and best practices.

Comments (0)

Login to post a comment.

Stay Updated

Get the latest articles delivered to your inbox.

We respect your privacy. Unsubscribe anytime.

Related Posts

AI Governance Crisis: Lawsuits, Terrorism, and Corporate Backlash Signal New Era

Today's AI headlines converge on a governance crisis: Apple sues OpenAI, extremist groups experiment with frontier models, and Meta retreats from a controversial image tool. The surge in capability forces tighter IP enforcement, new oversight bodies, and rapid policy shifts across the sector.

Read article

AI Agents Go Mainstream: From Clumsy Code Bots to Energy‑Hungry Data Centers | The AI Daily Roundup

AI assistants are moving from novelty to core infrastructure, sparking new tooling, audit frameworks, and energy concerns. Developers gain speed, but security, reliability, and macro‑economic risks rise sharply.

Read article

AI Hype Meets Reality: Security Risks, Thin ROI, and the Rise of Skepticism | The AI Daily Roundup

A wave of caution sweeps the AI industry: from Alibaba’s Claude Code ban and a surge in vulnerability disclosures to studies showing only 3% productivity gains, the market is demanding real security, transparent ROI, and user control.

Read article

AI Under Tightening Grip: Legal, Policy, and Security Pressures Converge | The AI Daily Roundup

Today's AI stories—from Japan’s patent ruling to OpenAI’s government equity talks—show a coordinated tightening of legal, policy, and security controls. The trend reshapes who profits, who bears risk, and how the industry will engineer, deploy, and market AI going forward.

Read article

AI’s Growing Pains: Compute Caps, Security Harnesses, and the Human Touch | The AI Daily Roundup

From Google throttling Meta’s Gemini usage to Semgrep’s harness‑driven security wins, today’s stories reveal a shift: AI is no longer a pure performance race—it’s a battle over compute, safety, and human oversight. Companies that solve these bottlenecks will win; those that ignore them will pay.

Read article

Popular Tags

#.env.example Node.js#0x profiling#10x faster python scraper tutorial#12-factor#2026#2FA#@nestjs/throttler#AI#AI Backend#AI Comparison