Step 5: Count clicks and report statistics
Each visit increments the link's counter, and GET /stats/<id> returns the link's details. The route order matters: /stats/abc123 must be checked before the general /<id> pattern would try to read it.
ExampleNode.js
const http = require("http");
const crypto = require("crypto");
const ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const links = new Map(); // id -> { url, clicks, created }
function randomId(length = 6) {
const bytes = crypto.randomBytes(length);
return [...bytes].map((b) => ALPHABET[b % ALPHABET.length]).join("");
}
function json(res, status, data, headers = {}) {
res.writeHead(status, { "Content-Type": "application/json", ...headers });
res.end(JSON.stringify(data));
}
function readJson(req) {
return new Promise((resolve) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => {
try { resolve(raw ? JSON.parse(raw) : {}); } catch { resolve(null); }
});
});
}
function parseTarget(input) {
if (typeof input !== "string" || input.length > 2000) return { error: "url must be a string of at most 2000 characters" };
let url;
try { url = new URL(input); } catch { return { error: "not a valid URL" }; }
if (url.protocol !== "http:" && url.protocol !== "https:") return { error: "only http and https links are allowed" };
return { url: url.toString() };
}
async function handle(req, res) {
if (req.method === "POST" && req.url === "/links") {
const body = await readJson(req);
if (body === null) return json(res, 400, { error: "invalid JSON" });
const target = parseTarget(body.url);
if (target.error) return json(res, 400, { error: target.error });
let id;
do { id = randomId(); } while (links.has(id));
links.set(id, { url: target.url, clicks: 0, created: "2026-11-05" });
return json(res, 201, { id });
}
const stats = req.url.match(/^\/stats\/([A-Za-z0-9]{6})$/);
if (req.method === "GET" && stats) {
const link = links.get(stats[1]);
if (!link) return json(res, 404, { error: "unknown short link" });
return json(res, 200, { id: stats[1], url: link.url, clicks: link.clicks, created: link.created });
}
const match = req.url.match(/^\/([A-Za-z0-9]{6})$/);
if (req.method === "GET" && match) {
const link = links.get(match[1]);
if (!link) return json(res, 404, { error: "unknown short link" });
link.clicks++; // count every visit
res.writeHead(302, { Location: link.url });
return res.end();
}
json(res, 404, { error: "Not found" });
}
const server = http.createServer(handle);
server.listen(0, async () => {
const base = `http://localhost:${server.address().port}`;
const { id } = await (await fetch(base + "/links", { method: "POST", body: JSON.stringify({ url: "https://example.com" }) })).json();
for (let i = 0; i < 3; i++) await fetch(`${base}/${id}`, { redirect: "manual" });
const stats = await (await fetch(`${base}/stats/${id}`)).json();
console.log("clicks:", stats.clicks, "| url:", stats.url, "| created:", stats.created);
console.log("unknown stats ->", (await fetch(`${base}/stats/zzzzzz`)).status);
server.close();
});Output
clicks: 3 | url: https://example.com/ | created: 2026-11-05 unknown stats -> 404
Counting inside the redirect handler is simple but means a failed count could break redirects: in a real service you would count asynchronously, after sending the response.
Quick check
Why is the stats route checked before the redirect route?
Finish the quick check (and any code exercise) to complete this lesson, or mark it done yourself.
Tip: use the left and right arrow keys to move between lessons.