Node.js and Express course menu
Node.js Project: Build a URL Shortener with Express
Build a URL shortener API: validating URLs, generating short codes with base62, custom aliases, HTTP redirects with 301 and 302, click counting and stats, rate limiting link creation, an expiry option, and tests.
A URL shortener turns a long address into a short one (sho.rt/a2X) that redirects to it, and counts clicks. It is a classic back-end project because it touches validation, ID generation, redirects, databases, statistics and abuse prevention.
The API#
Short codes with base62#
Codes use the 62 characters 0-9, a-z and A-Z, which are safe in URLs. Encoding the database id in base 62 gives short, unique codes: three characters cover 238,328 links, and four characters over 14 million.
const ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
export function encode(n) {
let code = "";
do {
code = ALPHABET[n % 62] + code;
n = Math.floor(n / 62);
} while (n > 0);
return code;
}import { encode } from "./src/base62.js";
for (const id of [1, 61, 62, 3843, 3844, 10000, 238327]) console.log(id, "→", encode(id));node try-base62.js1 → 1
61 → Z
62 → 10
3843 → ZZ
3844 → 100
10000 → 2Bi
238327 → ZZZSequential ids are predictable: anyone can try /1, /2, /3 and find every link. If links might be private, generate random codes instead (for example with crypto.randomInt for each character) and retry on the rare collision. This project starts ids at 10,000 so codes are at least three characters long.
The app#
import express from "express";
import { DatabaseSync } from "node:sqlite";
import { z } from "zod";
import { encode } from "./base62.js";
const NewLink = z.object({
url: z.url({ protocol: /^https?$/ }),
alias: z.string().regex(/^[A-Za-z0-9-]{4,30}$/, "alias must be 4-30 letters, digits or dashes").optional(),
});
export function createApp({ baseUrl = "http://localhost:3000", dbFile = ":memory:" } = {}) {
const db = new DatabaseSync(dbFile);
db.exec(`
CREATE TABLE IF NOT EXISTS links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE,
url TEXT NOT NULL,
clicks INTEGER NOT NULL DEFAULT 0
);
INSERT OR IGNORE INTO sqlite_sequence (name, seq) VALUES ('links', 9999);
`);
const app = express();
app.use(express.json());
app.post("/api/links", (req, res) => {
const parsed = NewLink.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`) });
}
const { url, alias } = parsed.data;
if (alias && db.prepare("SELECT 1 FROM links WHERE code = ?").get(alias)) {
return res.status(409).json({ error: "That alias is taken" });
}
const { lastInsertRowid } = db.prepare("INSERT INTO links (url) VALUES (?)").run(url);
const code = alias ?? encode(Number(lastInsertRowid));
db.prepare("UPDATE links SET code = ? WHERE id = ?").run(code, lastInsertRowid);
res.status(201).json({ code, shortUrl: `${baseUrl}/${code}`, url });
});
app.get("/api/links/:code", (req, res) => {
const link = db.prepare("SELECT code, url, clicks FROM links WHERE code = ?").get(req.params.code);
if (!link) return res.status(404).json({ error: "Link not found" });
res.json({ ...link });
});
app.get("/:code", (req, res) => {
const link = db.prepare("SELECT id, url FROM links WHERE code = ?").get(req.params.code);
if (!link) return res.status(404).send("Short link not found");
db.prepare("UPDATE links SET clicks = clicks + 1 WHERE id = ?").run(link.id);
res.redirect(302, link.url);
});
return app;
}import { createApp } from "./app.js";
createApp().listen(3000, () => console.log("Shortener on http://localhost:3000"));Design notes:
z.url({ protocol: /^https?$/ })accepts only http and https URLs, which blocksjavascript:and other dangerous schemes.- Seeding
sqlite_sequencemakes the first id 10000, so the first code is2Bi. - Redirects use 302 Found (temporary) so browsers ask the server every time and each click is counted. A 301 Moved Permanently would be cached by browsers, so repeat clicks would never reach the server.
res.json({ ...link })copies the database row into a normal object.
Try it#
curl -s -X POST localhost:3000/api/links -H "Content-Type: application/json" -d '{"url":"https://example.com/blog/learning-node-in-2026"}'
curl -s -X POST localhost:3000/api/links -H "Content-Type: application/json" -d '{"url":"https://example.com/sale","alias":"diwali-sale"}'
curl -s -X POST localhost:3000/api/links -H "Content-Type: application/json" -d '{"url":"https://example.com/other","alias":"diwali-sale"}'
curl -s -X POST localhost:3000/api/links -H "Content-Type: application/json" -d '{"url":"javascript:alert(1)"}'
curl -s -X POST localhost:3000/api/links -H "Content-Type: application/json" -d '{"url":"https://example.com","alias":"no"}'{"code":"2Bi","shortUrl":"http://localhost:3000/2Bi","url":"https://example.com/blog/learning-node-in-2026"}
{"code":"diwali-sale","shortUrl":"http://localhost:3000/diwali-sale","url":"https://example.com/sale"}
{"error":"That alias is taken"}
{"error":["url: Invalid URL"]}
{"error":["alias: alias must be 4-30 letters, digits or dashes"]}curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" localhost:3000/2Bi
curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" localhost:3000/2Bi
curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" localhost:3000/diwali-sale
curl -s localhost:3000/api/links/2Bi
curl -s -w " [%{http_code}]\n" localhost:3000/zzzz302 -> https://example.com/blog/learning-node-in-2026
302 -> https://example.com/blog/learning-node-in-2026
302 -> https://example.com/sale
{"code":"2Bi","url":"https://example.com/blog/learning-node-in-2026","clicks":2}
Short link not found [404]Each visit redirected with 302 and was counted: the stats show 2 clicks for 2Bi.
Tests#
import assert from "node:assert/strict";
import { test } from "node:test";
import request from "supertest";
import { createApp } from "../src/app.js";
test("short codes redirect and count clicks", async () => {
const app = createApp();
const created = await request(app).post("/api/links").send({ url: "https://example.com/a" });
assert.equal(created.status, 201);
assert.equal(created.body.code, "2Bi");
const visit = await request(app).get("/2Bi");
assert.equal(visit.status, 302);
assert.equal(visit.headers.location, "https://example.com/a");
const stats = await request(app).get("/api/links/2Bi");
assert.equal(stats.body.clicks, 1);
});
test("codes increase with each link", async () => {
const app = createApp();
const codes = [];
for (const n of [1, 2, 3]) codes.push((await request(app).post("/api/links").send({ url: `https://example.com/${n}` })).body.code);
assert.deepEqual(codes, ["2Bi", "2Bj", "2Bk"]);
});
test("rejects unsafe URLs and duplicate aliases", async () => {
const app = createApp();
assert.equal((await request(app).post("/api/links").send({ url: "ftp://example.com/file" })).status, 400);
await request(app).post("/api/links").send({ url: "https://example.com", alias: "promo" });
assert.equal((await request(app).post("/api/links").send({ url: "https://example.org", alias: "promo" })).status, 409);
});node --test✔ short codes redirect and count clicks (83.2635ms)
✔ codes increase with each link (17.653ms)
✔ rejects unsafe URLs and duplicate aliases (15.1817ms)
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 2656.2014Ideas to extend it#
- Rate limiting on
POST /api/links(see Security) to stop spam. - Expiry: an
expires_atcolumn, returning410 Gonefor expired links. - Click analytics: a
clickstable with time, referrer and country, aggregated per day. - Users who can list and delete their own links (Authentication).
- Caching popular codes in memory or Redis so redirects skip the database.
- Safety: check new URLs against a malware/phishing blocklist.
Key takeaway#
Finished the course? Test yourself with the Node.js interview questions.
Finished this lesson?