Tier 2 — Intermediate

URL Shortener

Build a URL shortener HTTP server — Node http module, Map store, base-62 IDs, redirects.

🐍 Python 🟨 JavaScript

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 Location header
  • 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

server.mjs
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

shortener.test.mjs
// 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/list

6. Run It

node server.mjs
node shortener.test.mjs   # tests run without a server

🎯 Try this next

  1. Persist the store to a JSON file on every change so URLs survive server restarts
  2. Add expiry: expiresIn parameter; automatically remove expired codes
  3. Add a rate limiter: limit each IP to 10 shortens per minute using a Map of {ip → count}
  4. 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

Try It Yourself