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.
{
"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.
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.
{"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.
{"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.)
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.
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.
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.
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.
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."
{"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.
HTTP/1.1 429 Too Many Requests
Validate URLs and reject dangerous patterns
Before inserting a URL, validate it:
- Check that the
urlfield exists and is a string. - Reject URLs that start with
javascript:(these can be XSS vectors). - Reject URLs that look like internal IPs (e.g.,
http://192.168.1.1orhttp://localhost).
Send a 400 error with a message if validation fails. For custom slugs, also reject if the slug contains non-alphanumeric characters.
{"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.
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));
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:
- POST /shorten with a valid URL returns a slug.
- GET /:slug with a real slug redirects (302).
- GET /:slug with a fake slug returns 404.
Run tests with npx jest test.js.
3 passed
Write a README with deployment notes
Create a README.md file in your project root. Include:
- Project description — one paragraph explaining what the service does.
- Setup — how to install dependencies and run locally.
- API — what endpoints exist and how to call them (show a curl example for POST and GET).
- Deployment — brief notes on deploying to Render, Fly, or Railway (pick one). Mention that you need to set environment variables for the database connection.
- 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.
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.