ShortIQ

ShortIQ

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.

June 19, 2026ShortIQ Editorial Team

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:

sql
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:

bash
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-dev

The 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:

typescript
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:

bash
npx tsc
pm2 start dist/index.js --name url-shortener
pm2 save
pm2 startup

FAQ

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.

We use this to improve tutorials, examples, and technical depth.