1. The Problem
Build a URL shortener that runs as a local HTTP server. Posting a long URL returns
a short code; visiting http://localhost:3000/<code> redirects to the
original. Track click counts. Support optional custom short codes.
Why this project? Node's built-in http module (no Express),
Map as the data store, base-62 ID generation, HTTP redirects (301/302), and URL parsing.
You build a real server from scratch.
2. How to Think About This
An HTTP server listens for requests. For a URL shortener, there are three kinds:
- POST /shorten — body contains
{ url, code? }; store it; return the short code - GET /<code> — look up the code; respond with HTTP 301 and a
Locationheader - GET /stats/<code> — return the URL and click count as JSON
The store is a Map<string, { url, clicks, createdAt }>.
Short codes are 5-char base-62 strings derived from the current timestamp — no database needed.
3. The Build
import { createServer } from "http";
const PORT = process.env.PORT ?? 3000;
const BASE_URL = `http://localhost:${PORT}`;
// ── Store (in-memory Map) ─────────────────────────────────────────────────
const store = new Map(); // code → { url, clicks, createdAt }
// ── ID generation — 5-char base-62 ───────────────────────────────────────
const CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
function toBase62(n) {
let r = "";
while (n > 0) { r = CHARS[n % 62] + r; n = Math.floor(n / 62); }
return r || "0";
}
function newCode() { return toBase62(Date.now()).slice(-5); }
// ── Helpers ───────────────────────────────────────────────────────────────
function readBody(req) {
return new Promise((resolve, reject) => {
let data = "";
req.on("data", chunk => { data += chunk; });
req.on("end", () => { try { resolve(JSON.parse(data)); } catch { reject(new Error("Invalid JSON")); } });
req.on("error", reject);
});
}
function send(res, status, body) {
const json = JSON.stringify(body, null, 2);
res.writeHead(status, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(json) });
res.end(json);
}
// ── Request handler ───────────────────────────────────────────────────────
const server = createServer(async (req, res) => {
const { method, url } = req;
try {
// POST /shorten → { url, code? }
if (method === "POST" && url === "/shorten") {
const { url: longUrl, code: customCode } = await readBody(req);
if (!longUrl?.startsWith("http")) return send(res, 400, { error: "url must start with http:// or https://" });
const code = customCode || newCode();
if (store.has(code)) return send(res, 409, { error: `Code "${code}" already taken` });
store.set(code, { url: longUrl, clicks: 0, createdAt: new Date().toISOString() });
return send(res, 201, { code, short: `${BASE_URL}/${code}`, original: longUrl });
}
// GET /list → all stored URLs
if (method === "GET" && url === "/list") {
const list = [...store.entries()].map(([code, d]) => ({ code, short: `${BASE_URL}/${code}`, ...d }));
return send(res, 200, list);
}
// GET /stats/:code → stats for one code
if (method === "GET" && url.startsWith("/stats/")) {
const code = url.slice(7);
const entry = store.get(code);
if (!entry) return send(res, 404, { error: `Code "${code}" not found` });
return send(res, 200, { code, short: `${BASE_URL}/${code}`, ...entry });
}
// GET /:code → redirect
if (method === "GET" && url.length > 1) {
const code = url.slice(1);
const entry = store.get(code);
if (!entry) return send(res, 404, { error: `Code "${code}" not found` });
entry.clicks++;
res.writeHead(301, { Location: entry.url });
return res.end();
}
// GET / → usage info
send(res, 200, {
usage: {
shorten: "POST /shorten body: { url, code? }",
redirect: "GET /<code>",
stats: "GET /stats/<code>",
list: "GET /list",
}
});
} catch (e) {
send(res, 500, { error: e.message });
}
});
server.listen(PORT, () => console.log(`URL shortener running at ${BASE_URL}`));
4. Test & Prove
// Test the pure store logic (no HTTP server needed)
function makeStore() {
const store = new Map();
const CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
function toBase62(n) { let r=""; while(n>0){r=CHARS[n%62]+r;n=Math.floor(n/62);} return r||"0"; }
function newCode() { return toBase62(Date.now()).slice(-5); }
function shorten(url, code) {
if(!url.startsWith("http")) throw new Error("invalid url");
const c = code || newCode();
if(store.has(c)) throw new Error("duplicate");
store.set(c, { url, clicks: 0 });
return c;
}
function resolve(c) {
const e = store.get(c); if(!e) throw new Error("not found");
e.clicks++; return e.url;
}
function stats(c) { return store.get(c) ?? null; }
return { shorten, resolve, stats };
}
let pass=0, fail=0;
function test(desc,fn){try{fn();console.log(" ✓",desc);pass++;}catch(e){console.log(" ✗",desc,"—",e.message);fail++;}}
const s = makeStore();
const code = s.shorten("https://example.com");
test("shorten returns a code", () => { if(!code || code.length < 1) throw new Error(code); });
test("resolve returns original URL", () => { if(s.resolve(code) !== "https://example.com") throw new Error(); });
test("click count increments", () => { s.resolve(code); if(s.stats(code).clicks !== 2) throw new Error(s.stats(code).clicks); });
test("custom code works", () => { const c = s.shorten("https://b.com","b"); if(c !== "b") throw new Error(c); });
test("duplicate code throws", () => { try{s.shorten("https://c.com","b");throw new Error("no throw");}catch(e){if(e.message==="no throw")throw e;} });
test("invalid URL throws", () => { try{s.shorten("not-a-url");throw new Error("no throw");}catch(e){if(e.message==="no throw")throw e;} });
test("missing code returns null", () => { if(s.stats("zzz999") !== null) throw new Error(); });
console.log(\`
\${pass} passed, \${fail} failed\`);
5. The Interface
Server: runs at http://localhost:3000. Use curl or any HTTP client.
# Start the server:
node server.mjs
# Shorten a URL:
curl -X POST http://localhost:3000/shorten -H "Content-Type: application/json" -d '{"url":"https://thecodex.expert/coding/languages/javascript/"}'
# → {"code":"4xK9m","short":"http://localhost:3000/4xK9m","original":"https://..."}
# Redirect (opens in browser, or curl -L to follow):
curl -L http://localhost:3000/4xK9m
# Stats:
curl http://localhost:3000/stats/4xK9m
# → {"code":"4xK9m","url":"...","clicks":1,"createdAt":"2026-06-27T..."}
# List all:
curl http://localhost:3000/list6. Run It
node server.mjs
node shortener.test.mjs # tests run without a server🎯 Try this next
- Persist the store to a JSON file on every change so URLs survive server restarts
- Add expiry:
expiresInparameter; automatically remove expired codes - Add a rate limiter: limit each IP to 10 shortens per minute using a Map of {ip → count}
- Port to Express.js — notice how much boilerplate disappears (routing, body parsing, JSON response)
What you practised
Building an HTTP server with Node's raw http module (no framework), Map as a production-style data store,
base-62 encoding for compact IDs, HTTP status codes (201 Created, 301 Redirect, 404, 409 Conflict),
and streaming request body reading with Promises.
Reference: Set & Map · Async & Promises · Error Handling