TidyURLs
Go to Tools
BLOG
The TidyURLsJournal

Practical guides, tutorials, and insights about URLs, SEO, web development, and online marketing. Learn, troubleshoot, and get the most of the web.

Back to the Journal
TidyURLs Journal

How to Build a URL Shortener with Analytics Using Node.js

Practical insights, clear explanations, and useful ideas for understanding the web.

October 25, 202613 min read6 topics
How to Build a URL Shortener with Analytics Using Node.js
Explore the details

Advertisement

#Node.js#Express#PostgreSQL#URL Shortener#Analytics#Backend Development
October 25, 202613 min read

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:

  1. A client sends a long destination URL to POST /api/links.
  2. The server validates the URL and generates a short code such as A8kd92p.
  3. The server stores the mapping in PostgreSQL.
  4. 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 UNIQUE constraint 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:, and file:.
  • URLs with embedded usernames or passwords.
  • localhost destinations.
  • 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.

StatusMeaningTypical use
301Permanent redirectStable links that should be cached by browsers and crawlers
302Temporary redirectDefault choice when destinations may change
307Temporary redirect preserving methodUseful when redirecting non-GET requests, uncommon for public short links
308Permanent redirect preserving methodPermanent 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:

  1. Creation rate limits: Limit how many links an IP address or account can create per time window.
  2. Authentication for heavy use: Anonymous creation is convenient but easier to abuse.
  3. Disable switch: Keep is_disabled so moderators or automated systems can deactivate a link without deleting evidence.
  4. Expiration support: Use expires_at for temporary campaigns or untrusted anonymous links.
  5. Destination restrictions: Block unsupported schemes and obvious local/private destinations.
  6. Logging and alerts: Watch for bursts of creations, repeated destinations, and high-click suspicious links.
  7. 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.