Intermediatejavascript~180 min

🔗 URL Shortener

Build a URL shortener service in Node and Express. Start with a tiny in-memory map, layer in Postgres for persistence + custom slugs, then add rate-limiting, validation, and a click-counter table. By the end you have a service that's actually useful.

You're going to build a working URL shortener in Node and Express. You'll start with a tiny in-memory map that shrinks URLs into 6-character slugs, then layer in Postgres for persistence so your links survive restarts, add support for custom slugs so users pick their own shorthand, and finish with rate-limiting, URL validation, a click counter, and a README with deployment instructions. By the end you have a service that's actually useful — something you could deploy to Render or Fly and run for real.

We're not racing. Each step is one idea. If a hint helps, take it — there's no penalty.

Before you start

Node.js: Use brew install node@20 if you don't have it, or brew upgrade node to update. Postgres: Install via brew install postgresql@14 (or a newer version), then start it with brew services start postgresql. Create a new database with createdb url_shortener_dev.

The walkthrough

Initialize the Node project

In your terminal, inside the url-shortener folder, initialize a new Node project. Create a package.json file with npm init -y (or pnpm init), then install Express:

npm init -y
npm install express

Open package.json in your editor and verify you see "express" in the "dependencies" section.

After this step your file should look like… (package.json)
{
  "name": "url-shortener",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "

Create a minimal Express server

Create a file called index.js. Write a minimal Express server that listens on port 3000 and responds with a simple message when you visit http://localhost:3000.

Run it with node index.js and confirm you see a "listening" message in the terminal.

After this step you should see…
Server listening on http://localhost:3000

Add a POST /shorten endpoint

Add a new route that listens for POST requests to /shorten. For now, just read the incoming JSON body and echo it back (don't generate a slug yet — that's next).

You'll need to tell Express to parse JSON: add app.use(express.json()); before your routes.

Test it with curl:

curl -X POST http://localhost:3000/shorten \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com"}'

You should see the JSON echoed back.

After this step you should see…
{"url":"https://example.com"}

Generate and store a 6-character slug

At the top of your file (above the routes), create an empty Map: const urlMap = new Map(). This will remember which slugs map to which URLs.

When a POST comes in, generate a random 6-character slug using alphanumeric characters (a–z, A–Z, 0–9). Store the mapping in the Map: urlMap.set(slug, url). Return the slug to the client.

After this step you should see…
{"slug":"[a-zA-Z0-9]{6}"}

GET /:slug redirects to the stored URL

Add a new GET route that matches /:slug (where slug is a parameter you can read from req.params). Look up the slug in the Map. If it exists, send a 302 redirect to the original URL. If it doesn't exist, send a 404.

Test it:

curl -i http://localhost:3000/abc123

(You'll need to replace abc123 with a real slug from a prior POST.)

After this step you should see…
HTTP/1.1 302 Found

Set up PostgreSQL and create the database

Using your terminal, connect to PostgreSQL and create a database called url_shortener_dev:

psql
# or if you're on Linux with a fresh install:
sudo -u postgres psql

Once inside the psql prompt (postgres=#), run:

CREATE DATABASE url_shortener_dev;
\c url_shortener_dev

Verify you're connected (the prompt will show url_shortener_dev=#). You can exit with \q.

After this step you should see…
url_shortener_dev=#

Add the pg driver and set up a connection pool

Install the Node PostgreSQL driver:

npm install pg

At the top of your index.js, create a connection pool so your server can reuse database connections instead of opening a new one for every query:

const { Pool } = require("pg");

const pool = new Pool({
  host: "localhost",
  port: 5432,
  database: "url_shortener_dev",
  user: "postgres",
  password: "", // leave blank if you're on macOS with Homebrew's default
});

For now, don't run any queries yet. Just verify the pool can connect by checking that the server still starts without errors.

After this step you should see…
Server listening on http://localhost:3000

Create the links table and insert the first row

Run a SQL command to create a table in your database. Use psql to connect and run:

CREATE TABLE links (
  slug VARCHAR(10) PRIMARY KEY,
  url TEXT NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Verify the table exists by listing tables with \dt. You should see links in the output.

After this step you should see…
public | links

Move URL storage from the Map to Postgres

Replace the in-memory urlMap.set() and urlMap.get() calls with database queries.

When a POST comes in with a URL, insert it into the links table. When a GET request comes in, query the table instead of the Map. You can remove the const urlMap = new Map() line.

Test it by making a POST request, stopping the server (Ctrl+C), restarting it, and visiting the slug. It should still redirect — that's how you know it's persisted.

After this step you should see…
HTTP/1.1 302 Found

Accept optional custom slugs and validate uniqueness

Modify the POST endpoint so the client can optionally provide their own slug in the request body: { url: "https://...", customSlug: "myslug" }.

If a customSlug is provided, use it. If not, generate one randomly as before. Before inserting, check if the slug already exists — if it does, reject with a 400 error saying "slug already taken."

After this step you should see…
{"error":"slug already taken"}

Add rate limiting to POST /shorten

Install the rate-limiting middleware:

npm install express-rate-limit

At the top of your file, require it and create a limiter that allows 30 requests per minute per IP address:

const rateLimit = require("express-rate-limit");

const shortenLimiter = rateLimit({
  windowMs: 60 * 1000, // 1 minute
  max: 30, // 30 requests max
  message: "Too many shorten requests, please try again later",
});

Apply the limiter to the POST route: app.post("/shorten", shortenLimiter, async (req, res) => { ... }).

Test it by firing 31 requests at the endpoint — the 31st should be rejected with a 429 status.

After this step you should see…
HTTP/1.1 429 Too Many Requests

Validate URLs and reject dangerous patterns

Before inserting a URL, validate it:

  1. Check that the url field exists and is a string.
  2. Reject URLs that start with javascript: (these can be XSS vectors).
  3. Reject URLs that look like internal IPs (e.g., http://192.168.1.1 or http://localhost).

Send a 400 error with a message if validation fails. For custom slugs, also reject if the slug contains non-alphanumeric characters.

After this step you should see…
{"error":"invalid URL"}

Create a clicks table for tracking redirects

Create a new table to log every time someone visits a short link:

CREATE TABLE clicks (
  id SERIAL PRIMARY KEY,
  slug VARCHAR(10) NOT NULL,
  user_agent TEXT,
  ip_address TEXT,
  clicked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (slug) REFERENCES links(slug)
);

Run this in psql connected to url_shortener_dev. Verify with \dt — you should see both links and clicks.

After this step you should see…
public | clicks

Log a click every time someone redirects

Modify the GET :slug handler to log a click in the clicks table before redirecting. Extract the client's IP from req.ip and the user-agent from req.headers['user-agent']. Insert a new row into the clicks table.

Don't wait for the insert to finish — use "fire and forget" so the redirect happens immediately:

pool.query("INSERT INTO ...", [...]).catch(err => console.error(err));
After this step you should see…
HTTP/1.1 302 Found

Write tests for POST and GET endpoints

Install a testing library:

npm install --save-dev supertest jest

Create a test.js file. Write at least three tests using supertest:

  1. POST /shorten with a valid URL returns a slug.
  2. GET /:slug with a real slug redirects (302).
  3. GET /:slug with a fake slug returns 404.

Run tests with npx jest test.js.

After this step you should see…
3 passed

Write a README with deployment notes

Create a README.md file in your project root. Include:

  1. Project description — one paragraph explaining what the service does.
  2. Setup — how to install dependencies and run locally.
  3. API — what endpoints exist and how to call them (show a curl example for POST and GET).
  4. Deployment — brief notes on deploying to Render, Fly, or Railway (pick one). Mention that you need to set environment variables for the database connection.
  5. Future work — what you'd add next (e.g., custom expiration times, admin analytics page, bulk shortening).

Run npx jest test.js once more to confirm tests pass, then you're done.

After this step you should see…
3 passed

What's the difference between a 302 and a 301 redirect?

Why do we use parameterized queries ($1, $2) instead of string concatenation?

What does a connection pool do?

You did it

Run the tests one more time. Watch them pass. You built:

  • A Node + Express server with multiple routes
  • In-memory storage that you then replaced with Postgres persistence
  • A database schema with tables and foreign keys
  • An analytics feature (click logging) that works in the background
  • Input validation that rejects unsafe URLs
  • Rate limiting to protect against abuse
  • Full test coverage with supertest
  • A README with deployment instructions

Every one of those is a piece of a real web service. You've built something close to what powers the actual bit.ly or tinyurl.com — not the full scale, but the same architecture.

Stretch goals