A URL shortener looks simple from the outside: save a long URL, generate a short code, redirect visitors, and count clicks. In practice, a reliable implementation needs careful decisions around code generation, redirects, analytics storage, rate limiting, privacy, and abuse prevention.
This tutorial shows how to build URL shortener Node.js functionality with Express and PostgreSQL. You will create an API that accepts destination URLs, generates collision-resistant short codes, redirects users, tracks clicks, and exposes basic analytics. The implementation is intentionally small enough to learn from, but it includes the security and operational details that beginner examples often skip.
Key takeaways
- Generate short codes with enough entropy, and handle rare database collisions safely.
- Store links and click events separately so redirects stay fast while analytics remain queryable.
- Use temporary redirects by default while developing, and choose permanent redirects only when you understand caching implications.
- Validate destination URLs, restrict dangerous schemes, and add abuse controls such as rate limits and link disabling.
- Click analytics should avoid collecting unnecessary personal data; hashing IP addresses is better than storing raw IPs, but it is still sensitive.
- A URL parser helps inspect URL structure, but it is not a complete security control by itself.
Architecture of the shortener
The basic flow has four parts:
- A client sends a long destination URL to
POST /api/links. - The server validates the URL and generates a short code such as
A8kd92p. - The server stores the mapping in PostgreSQL.
- A visitor requests
GET /A8kd92p; the server records a click and redirects to the destination.
For analytics, the app stores one row per click. That gives you flexibility for reports such as total clicks, referrers, user agents, and daily click counts. For high-traffic systems, you might eventually replace raw per-click inserts with a queue, stream processor, or aggregated counters, but raw events are easier to understand and debug when you are starting out.
Project setup
Create a new project and install the dependencies:
mkdir node-url-shortener
cd node-url-shortener
npm init -y
npm install express pg dotenv express-rate-limit helmet
Use Node.js with ES modules by adding this to package.json:
{
"type": "module",
"scripts": {
"dev": "node server.mjs"
}
}
Create a .env file:
DATABASE_URL=postgres://postgres:postgres@localhost:5432/url_shortener
BASE_URL=http://localhost:3000
IP_HASH_SECRET=replace-this-with-a-long-random-secret
TRUST_PROXY=false
PORT=3000
IP_HASH_SECRET is used to create a one-way hash of visitor IP addresses for basic abuse analysis without storing the raw IP. In production, use a strong secret from your secret manager, not a value committed to source control.
Database schema for links and clicks
Create a PostgreSQL database named url_shortener, then run this schema:
CREATE TABLE links (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(32) NOT NULL UNIQUE,
destination_url TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ,
is_disabled BOOLEAN NOT NULL DEFAULT false,
click_count BIGINT NOT NULL DEFAULT 0
);
CREATE TABLE clicks (
id BIGSERIAL PRIMARY KEY,
link_id BIGINT NOT NULL REFERENCES links(id) ON DELETE CASCADE,
clicked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
referrer TEXT,
user_agent TEXT,
ip_hash TEXT
);
CREATE INDEX idx_links_code ON links(code);
CREATE INDEX idx_clicks_link_id_clicked_at ON clicks(link_id, clicked_at DESC);
This schema keeps the current redirect target in links and stores analytics events in clicks. The click_count column is denormalized for fast total-count reads. The raw click rows remain useful for time-series reports.
Advertisement
Building the Node.js server
Create server.mjs:
import 'dotenv/config';
import express from 'express';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import crypto from 'node:crypto';
import net from 'node:net';
import pg from 'pg';
const { Pool } = pg;
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const port = Number(process.env.PORT || 3000);
const baseUrl = process.env.BASE_URL || `http://localhost:${port}`;
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
app.use(helmet());
app.use(express.json({ limit: '16kb' }));
if (process.env.TRUST_PROXY === 'true') {
app.set('trust proxy', 1);
}
const createLinkLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 20,
standardHeaders: 'draft-7',
legacyHeaders: false,
message: { error: 'Too many short links created. Try again later.' }
});
function randomCode(length = 7) {
const bytes = crypto.randomBytes(length);
let code = '';
for (const byte of bytes) {
code += alphabet[byte % alphabet.length];
}
return code;
}
function hashIp(ip) {
const secret = process.env.IP_HASH_SECRET;
if (!secret) {
return null;
}
return crypto
.createHmac('sha256', secret)
.update(ip || '')
.digest('hex');
}
function isBlockedIpLiteral(hostname) {
const version = net.isIP(hostname);
if (version === 0) {
return false;
}
if (version === 4) {
return (
hostname.startsWith('10.') ||
hostname.startsWith('127.') ||
hostname.startsWith('169.254.') ||
hostname.startsWith('192.168.') ||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname)
);
}
const normalized = hostname.toLowerCase();
return (
normalized === '::1' ||
normalized.startsWith('fc') ||
normalized.startsWith('fd') ||
normalized.startsWith('fe80:')
);
}
function validateDestinationUrl(input) {
if (typeof input !== 'string' || input.length > 2048) {
return { ok: false, error: 'Destination URL must be a string under 2048 characters.' };
}
let url;
try {
url = new URL(input);
} catch {
return { ok: false, error: 'Enter a valid absolute URL.' };
}
if (!['http:', 'https:'].includes(url.protocol)) {
return { ok: false, error: 'Only http and https URLs are allowed.' };
}
if (url.username || url.password) {
return { ok: false, error: 'URLs containing usernames or passwords are not allowed.' };
}
const hostname = url.hostname.toLowerCase();
if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
return { ok: false, error: 'Localhost destinations are not allowed.' };
}
if (isBlockedIpLiteral(hostname)) {
return { ok: false, error: 'Private or local IP destinations are not allowed.' };
}
return { ok: true, url: url.toString() };
}
async function createShortLink(destinationUrl) {
for (let attempt = 0; attempt < 5; attempt += 1) {
const code = randomCode(7);
try {
const result = await pool.query(
'INSERT INTO links (code, destination_url) VALUES ($1, $2) RETURNING code',
[code, destinationUrl]
);
return result.rows[0].code;
} catch (error) {
if (error.code === '23505') {
continue;
}
throw error;
}
}
throw new Error('Could not generate a unique short code.');
}
async function trackClick(link, req) {
const ipHash = hashIp(req.ip);
const referrer = req.get('referer') || null;
const userAgent = req.get('user-agent') || null;
await pool.query(
`INSERT INTO clicks (link_id, referrer, user_agent, ip_hash)
VALUES ($1, $2, $3, $4)`,
[link.id, referrer, userAgent, ipHash]
);
await pool.query(
'UPDATE links SET click_count = click_count + 1 WHERE id = $1',
[link.id]
);
}
app.post('/api/links', createLinkLimiter, async (req, res, next) => {
try {
const validation = validateDestinationUrl(req.body.destinationUrl);
if (!validation.ok) {
return res.status(400).json({ error: validation.error });
}
const code = await createShortLink(validation.url);
const shortUrl = new URL(`/${code}`, baseUrl).toString();
return res.status(201).json({
code,
shortUrl,
destinationUrl: validation.url
});
} catch (error) {
return next(error);
}
});
app.get('/api/links/:code/analytics', async (req, res, next) => {
try {
const linkResult = await pool.query(
'SELECT id, code, destination_url, click_count, created_at FROM links WHERE code = $1',
[req.params.code]
);
const link = linkResult.rows[0];
if (!link) {
return res.status(404).json({ error: 'Short link not found.' });
}
const dailyResult = await pool.query(
`SELECT date_trunc('day', clicked_at) AS day, COUNT(*)::int AS clicks
FROM clicks
WHERE link_id = $1 AND clicked_at >= now() - interval '30 days'
GROUP BY day
ORDER BY day ASC`,
[link.id]
);
const referrerResult = await pool.query(
`SELECT COALESCE(referrer, 'Direct or unknown') AS referrer, COUNT(*)::int AS clicks
FROM clicks
WHERE link_id = $1
GROUP BY COALESCE(referrer, 'Direct or unknown')
ORDER BY clicks DESC
LIMIT 10`,
[link.id]
);
return res.json({
code: link.code,
destinationUrl: link.destination_url,
totalClicks: Number(link.click_count),
createdAt: link.created_at,
dailyClicks: dailyResult.rows,
topReferrers: referrerResult.rows
});
} catch (error) {
return next(error);
}
});
app.get('/:code', async (req, res, next) => {
try {
const { code } = req.params;
if (!/^[A-Za-z0-9_-]{4,32}$/.test(code)) {
return res.status(404).send('Not found');
}
const result = await pool.query(
`SELECT id, destination_url
FROM links
WHERE code = $1
AND is_disabled = false
AND (expires_at IS NULL OR expires_at > now())`,
[code]
);
const link = result.rows[0];
if (!link) {
return res.status(404).send('Not found');
}
trackClick(link, req).catch((error) => {
console.error('Click tracking failed:', error);
});
return res.redirect(302, link.destination_url);
} catch (error) {
return next(error);
}
});
app.use((error, req, res, next) => {
console.error(error);
return res.status(500).json({ error: 'Internal server error.' });
});
app.listen(port, () => {
console.log(`URL shortener listening on ${baseUrl}`);
});
Run the server:
npm run dev
Create a short link:
curl -X POST http://localhost:3000/api/links \
-H 'Content-Type: application/json' \
-d '{"destinationUrl":"https://example.com/products?utm_source=test"}'
Visit the returned shortUrl, then check analytics:
curl http://localhost:3000/api/links/YOUR_CODE/analytics
Why short code generation matters
Short codes must be hard to guess at scale and unlikely to collide. A 7-character code using 62 characters gives a large search space for a small tutorial app. For public production systems, consider longer codes, custom aliases with moderation, and monitoring for enumeration attempts.
The code above does two important things:
- It uses
crypto.randomBytes()instead of predictable counters. - It relies on the database
UNIQUEconstraint and retries if a collision occurs.
Do not generate a code, query to see if it exists, and then insert it without a uniqueness constraint. Two concurrent requests can still choose the same code between the check and insert. The database constraint is the reliable source of truth.
URL validation and normalization
The built-in URL class parses absolute URLs according to modern URL parsing behavior. This helps reject malformed input and normalize details such as case and encoding. You can also inspect URL components when debugging how a destination URL is split into protocol, hostname, path, query string, and fragment.
However, URL parsing is not the same as abuse prevention. A parser can tell you what a URL looks like; it cannot decide whether a destination is trustworthy, legal, safe, or appropriate for your platform.
The tutorial validation function rejects:
- Non-HTTP schemes such as
javascript:,data:, andfile:. - URLs with embedded usernames or passwords.
localhostdestinations.- Literal private, loopback, or link-local IP addresses.
That is a useful starting point, but it is not complete protection for every use case. If your application fetches destination URLs server-side for previews, screenshots, malware scans, or metadata, you need stronger SSRF defenses: controlled outbound networking, DNS resolution checks, redirect handling rules, timeouts, response size limits, and infrastructure-level egress restrictions.
Redirect status codes: 301, 302, 307, or 308?
For a URL shortener, redirect choice affects caching and behavior.
| Status | Meaning | Typical use |
|---|---|---|
301 | Permanent redirect | Stable links that should be cached by browsers and crawlers |
302 | Temporary redirect | Default choice when destinations may change |
307 | Temporary redirect preserving method | Useful when redirecting non-GET requests, uncommon for public short links |
308 | Permanent redirect preserving method | Permanent method-preserving redirect |
The example uses 302 because it is safer while learning and while links may be edited, disabled, or moderated. If you switch to 301 or 308, browsers and intermediaries may cache the destination aggressively, making later changes harder to observe.
Advertisement
Click tracking design
The redirect endpoint records clicks asynchronously:
trackClick(link, req).catch((error) => {
console.error('Click tracking failed:', error);
});
return res.redirect(302, link.destination_url);
This keeps the redirect responsive even if analytics insertion fails. The trade-off is that a click may be missed if the process exits before the insert completes. For a more robust production design, send click events to a queue and process them in the background.
A few analytics fields are useful without being overly invasive:
- Timestamp.
- Referrer header, when present.
- User-Agent header.
- HMAC hash of the IP address using a secret key.
Avoid storing raw IP addresses unless you have a clear need, a retention policy, and appropriate privacy notices. Even hashed IPs can still be personal or sensitive data depending on your jurisdiction and implementation.
Rate limiting and abuse prevention
Public URL shorteners are attractive for spam, phishing, malware distribution, and link cloaking. Technical controls cannot eliminate abuse, but they can reduce it and make response easier.
Start with these controls:
- Creation rate limits: Limit how many links an IP address or account can create per time window.
- Authentication for heavy use: Anonymous creation is convenient but easier to abuse.
- Disable switch: Keep
is_disabledso moderators or automated systems can deactivate a link without deleting evidence. - Expiration support: Use
expires_atfor temporary campaigns or untrusted anonymous links. - Destination restrictions: Block unsupported schemes and obvious local/private destinations.
- Logging and alerts: Watch for bursts of creations, repeated destinations, and high-click suspicious links.
- Administrative review: Add an internal workflow for reports and takedowns before operating publicly.
The express-rate-limit configuration in this tutorial protects the creation endpoint. You may also want separate limits for analytics endpoints to prevent scraping or expensive queries.
Adding authentication before production
The tutorial analytics endpoint is intentionally open so you can test it quickly. Do not expose analytics for all short codes in a real product unless that is a deliberate public feature.
Before production, add at least one of the following:
- User accounts and ownership checks.
- Private API keys for link creation and analytics access.
- Signed admin sessions.
- Separate public and private analytics modes.
Authorization checks should happen on every management endpoint: create, edit, disable, delete, and analytics.
Common mistakes and pitfalls
Using predictable IDs as short codes
A sequential code such as 1, 2, 3 is easy to enumerate. Attackers, competitors, or curious users can discover links by guessing. Random codes are a better default.
Trusting the Host header when building short URLs
Do not build public short URLs directly from req.headers.host unless you validate it. Host header spoofing can produce misleading links. The tutorial uses BASE_URL from configuration instead.
Treating URL validation as a complete safety system
Rejecting bad schemes is necessary, but it does not determine whether a destination is malicious. Public shorteners need reporting, monitoring, moderation, and takedown processes.
Making analytics writes block redirects
If every redirect waits for multiple analytics queries, users will feel the latency. Keep redirects simple, and move heavier analytics work out of the request path as traffic grows.
Forgetting proxy configuration
If your app runs behind a reverse proxy, req.ip may be the proxy IP unless Express is configured correctly. Set trust proxy only when you understand your hosting topology. Trusting arbitrary proxy headers on the open internet can let clients spoof IP addresses.
Logging too much data
Access logs, error logs, and analytics tables can accidentally collect full URLs containing tokens or personal data. Be careful with query strings, retention periods, and who can access logs.
Production hardening checklist
Before launching a public service, review this checklist:
- Use HTTPS everywhere.
- Put the app behind a reverse proxy or load balancer with request size limits.
- Require accounts or API keys for link creation if abuse risk is high.
- Add ownership and authorization checks for analytics.
- Use longer short codes for public, high-volume systems.
- Add link disabling, expiration, and moderation tooling.
- Store secrets in a secret manager or deployment platform, not in source control.
- Back up PostgreSQL and test restores.
- Add database connection limits and timeouts.
- Monitor error rates, redirect latency, link creation spikes, and database growth.
- Define a privacy-aware retention policy for click events.
- Consider a queue for analytics writes if redirect traffic grows.
Scaling options
The simple design can handle learning projects and modest internal tools, but scaling changes the trade-offs.
For higher traffic, consider:
- Caching link lookups in Redis or an edge cache to reduce database reads.
- Queueing click events so redirects do not depend on analytics storage.
- Partitioning click tables by time if analytics volume becomes large.
- Separating redirect and admin services so management traffic cannot slow redirects.
- Using CDN or edge redirects for globally distributed low-latency routing.
Do not add these too early. The PostgreSQL-backed version is easier to reason about and is often the right first implementation.
Conclusion
You can build a URL shortener with analytics in Node.js using a small Express server and PostgreSQL, but the important details are in the edges: collision-safe short codes, clear redirect semantics, privacy-conscious analytics, rate limiting, and practical abuse controls.
The tutorial implementation is a strong learning foundation. To make it production-ready, add authentication, moderation workflows, stronger monitoring, privacy policies, and infrastructure-level protections appropriate to your deployment environment.
