Development
How to Build a URL Shortener with Node.js and PostgreSQL
Step-by-step guide to building a URL shortener API with Node.js, Express, and PostgreSQL. Covers short code generation, redirect handling, click tracking, rate limiting, and deploying on a VPS with PM2 and Nginx.
What We Will Build
A URL shortener takes a long URL and produces a short code that redirects to it. This is one of the best learning projects for a Node.js backend because it touches every core concept: database design, REST API design, HTTP redirects, unique ID generation, analytics tracking, and rate limiting.
We will build a production-ready URL shortener API using Node.js, Express, and PostgreSQL. The API will support creating short links, custom slugs, click tracking with timestamp and user agent, and rate limiting to prevent abuse. We will also cover deploying it on a Linux VPS with PM2 and Nginx.
Database Schema
Start with a PostgreSQL database. Create the database and the links table:
CREATE DATABASE urlshortener;
\c urlshortener
CREATE TABLE links (
id SERIAL PRIMARY KEY,
slug VARCHAR(20) NOT NULL UNIQUE,
target_url TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ
);
CREATE TABLE clicks (
id SERIAL PRIMARY KEY,
link_id INTEGER REFERENCES links(id) ON DELETE CASCADE,
clicked_at TIMESTAMPTZ DEFAULT NOW(),
user_agent TEXT,
ip_address INET
);
CREATE INDEX idx_clicks_link_id ON clicks(link_id);
CREATE INDEX idx_links_slug ON links(slug);Project Setup and Dependencies
Initialise the project and install dependencies:
mkdir url-shortener && cd url-shortener
npm init -y
npm install express pg nanoid express-rate-limit dotenv
npm install -D typescript @types/express @types/pg ts-node-devThe API: Create and Redirect
Create the main application file with the two core endpoints: POST /links to create a short link, and GET /:slug to redirect:
import express from 'express';
import { Pool } from 'pg';
import { nanoid } from 'nanoid';
import rateLimit from 'express-rate-limit';
const app = express();
app.use(express.json());
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const createLimiter = rateLimit({ windowMs: 60_000, max: 20 });
app.post('/links', createLimiter, async (req, res) => {
const { url, slug, expiresAt } = req.body;
if (!url) return res.status(400).json({ error: 'url is required' });
const shortSlug = slug ?? nanoid(7);
const result = await pool.query(
'INSERT INTO links (slug, target_url, expires_at) VALUES ($1, $2, $3) RETURNING *',
[shortSlug, url, expiresAt ?? null]
);
res.status(201).json(result.rows[0]);
});
app.get('/:slug', async (req, res) => {
const { slug } = req.params;
const result = await pool.query(
'SELECT * FROM links WHERE slug = $1 AND (expires_at IS NULL OR expires_at > NOW())',
[slug]
);
if (result.rows.length === 0) return res.status(404).json({ error: 'Link not found' });
const link = result.rows[0];
await pool.query(
'INSERT INTO clicks (link_id, user_agent, ip_address) VALUES ($1, $2, $3)',
[link.id, req.headers['user-agent'] ?? null, req.ip]
);
res.redirect(301, link.target_url);
});
app.get('/links/:slug/stats', async (req, res) => {
const result = await pool.query(
'SELECT COUNT(*) AS total_clicks FROM clicks c JOIN links l ON c.link_id = l.id WHERE l.slug = $1',
[req.params.slug]
);
res.json(result.rows[0]);
});
app.listen(3000, () => console.log('Listening on port 3000'));Deployment with PM2 and Nginx
Build the TypeScript and start with PM2:
npx tsc
pm2 start dist/index.js --name url-shortener
pm2 save
pm2 startupFAQ
How do I generate unique short codes?
Use nanoid, a tiny library that generates cryptographically secure random URL-friendly strings. nanoid(7) generates a 7-character string with 64^7 possible values (~4 trillion), sufficient to avoid collisions for most use cases. If you need collision detection, check for a duplicate slug after insertion and retry with a new code if the insert fails with a unique constraint violation.
Should I use 301 or 302 for redirects?
302 (temporary) for URL shorteners where you want to track every click. Browsers cache 301 permanent redirects and stop requesting the short URL, meaning you lose click analytics after the first visit. Use 302 so every click goes through your server and can be logged. Use 301 only if you are certain the target URL will never change and you do not need per-click analytics.
How do I prevent abuse of the link creation endpoint?
Apply rate limiting per IP address with express-rate-limit. Require authentication (API key or JWT) for link creation. Validate that the target URL uses http or https and is a valid URL. Optionally, check the target URL against a blocklist of known phishing or malware domains. Monitor for spikes in creation requests and block abusive IPs automatically.
How do I scale a URL shortener for high traffic?
Add Redis caching for slug lookups — the redirect path is the hot path and should not hit PostgreSQL on every request. Cache the slug-to-URL mapping in Redis with a TTL matching the link expiry. For the creation endpoint, PostgreSQL handles millions of writes per day without special scaling. Use a CDN for the redirect endpoint if globally distributed latency matters. Add a read replica for analytics queries to avoid impacting redirect performance.
Related free tools
If you want to turn this topic into action, use one of ShortIQ's free tools for campaign planning, UTM structure, or QR distribution.
Continue Reading
Explore more guides on link shortener SaaS strategy, Bitly alternatives, and white label link management.
Free newsletter
Get new guides in your inbox
We publish practical guides on dev tooling, prompt engineering, marketing workflows, and deployment. No fluff — straight to the point.
No spam. Unsubscribe any time.
Was this article helpful?
Tell us if this guide solved the problem or what was still missing. We use this to improve the blog and only follow up if you explicitly allow it.