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.
Senior Developer

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 countsGET /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/shortenwith an optionalcustomCodefield; 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_atcolumn tolinksand 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.