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, 20268 min read6 topics
How to Build a URL Shortener with Analytics Using Node.js
Explore the details
#Node.js#URL shortener#Express#PostgreSQL#Web analytics#Rate limiting
October 25, 20268 min read

A URL shortener looks simple: accept a long link, create a compact code, and redirect visitors. But a useful service also needs collision-safe codes, reliable storage, click tracking, rate limits, and safeguards against abuse. This tutorial builds a small Node.js and PostgreSQL version and explains where the example needs more work before production use.

Key takeaways

  • Generate short codes with a cryptographically secure random source, and let a database uniqueness constraint resolve collisions.
  • Validate submitted destinations and accept only the URL schemes your service intends to support.
  • Store links and click events separately so analytics can grow without complicating redirect records.
  • Apply rate limits to link creation, and account for shared deployments when choosing a rate-limit store.
  • Treat analytics as best-effort in the redirect path if tracking failures should not prevent visitors from reaching their destination.

Plan the data model and redirect flow

A shortener has two main operations. A creation endpoint validates a destination, generates a code, and saves the mapping. A redirect endpoint looks up that code, records a click, and sends an HTTP redirect to the saved destination.

Use a relational database constraint as the final authority on uniqueness. Checking whether a code exists before inserting it is not enough: two concurrent requests could both pass that check. Instead, attempt an insert and retry if the database reports a code conflict.

Here is a minimal PostgreSQL schema:

CREATE TABLE short_links (
  code TEXT PRIMARY KEY,
  destination TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE link_clicks (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  code TEXT NOT NULL REFERENCES short_links(code) ON DELETE CASCADE,
  clicked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX link_clicks_code_clicked_at_idx
  ON link_clicks (code, clicked_at);

The primary key on short_links.code prevents duplicate codes. The click index helps queries that count or filter events by link and time. For high-volume analytics, you may eventually move events to a queue or analytics store rather than keeping every event in the application database.

Generate short codes safely

A short code must be difficult to guess and unique in the database. Sequential IDs are compact, but they reveal creation order and can make it easy to enumerate links. Random codes avoid that predictable sequence. The following helper uses Node.js's cryptographic random generator:

import { randomBytes } from 'node:crypto';

function generateCode() {
  return randomBytes(6).toString('base64url');
}

Six random bytes encode to eight base64url characters. The code is not guaranteed to be unique; the database constraint and retry loop below handle the rare collision. If your service has a very large number of links, consider the probability of collisions when choosing code length, and monitor retries.

A URL parser can help you inspect a submitted link's components while developing or debugging. It is not a substitute for application-side validation or abuse controls.

Build the creation and redirect endpoints

This example uses Express 5 and the pg package. It assumes you have created a PostgreSQL connection pool named pool, set PUBLIC_BASE_URL to your public HTTPS origin, and installed express-rate-limit. Configure and test your database connection and deployment settings separately.

import express from 'express';
import rateLimit from 'express-rate-limit';
import { randomBytes } from 'node:crypto';

const app = express();
app.use(express.json({ limit: '4kb' }));

const publicBaseUrl = new URL(process.env.PUBLIC_BASE_URL);
if (publicBaseUrl.protocol !== 'https:') {
  throw new Error('PUBLIC_BASE_URL must use HTTPS');
}

const createLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 20,
  standardHeaders: 'draft-8',
  legacyHeaders: false,
});

function generateCode() {
  return randomBytes(6).toString('base64url');
}

function parseDestination(value) {
  if (typeof value !== 'string' || value.length > 2048) {
    return null;
  }

  try {
    const url = new URL(value);
    if (!['https:', 'http:'].includes(url.protocol)) return null;
    if (url.username || url.password) return null;
    return url.toString();
  } catch {
    return null;
  }
}

app.post('/api/links', createLimiter, async (req, res) => {
  const destination = parseDestination(req.body?.url);
  if (!destination) {
    return res.status(400).json({ error: 'Provide a valid HTTP or HTTPS URL.' });
  }

  // Retry only when a generated code conflicts with the database key.
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const code = generateCode();
    const result = await pool.query(
      `INSERT INTO short_links (code, destination)
       VALUES ($1, $2)
       ON CONFLICT (code) DO NOTHING
       RETURNING code`,
      [code, destination],
    );

    if (result.rowCount === 1) {
      return res.status(201).json({
        code,
        shortUrl: new URL(code, publicBaseUrl).toString(),
      });
    }
  }

  return res.status(503).json({ error: 'Could not allocate a short code. Try again.' });
});

app.get('/:code', async (req, res) => {
  const { code } = req.params;
  if (!/^[A-Za-z0-9_-]{8}$/.test(code)) {
    return res.sendStatus(404);
  }

  const result = await pool.query(
    'SELECT destination FROM short_links WHERE code = $1',
    [code],
  );
  if (result.rowCount === 0) {
    return res.sendStatus(404);
  }

  // Tracking is best-effort here: a logging failure should not block the redirect.
  try {
    await pool.query('INSERT INTO link_clicks (code) VALUES ($1)', [code]);
  } catch {
    console.error('Click event could not be recorded.');
  }

  return res.redirect(302, result.rows[0].destination);
});

The parameterized SQL queries keep user-controlled values out of SQL syntax. The HTTP and HTTPS check rejects schemes such as javascript:; rejecting URL credentials also avoids accepting confusing links that embed a username and password. These checks validate URL structure, not whether a destination is trustworthy. A shortener that allows arbitrary destinations can still be used for phishing or other abuse.

The rate limiter above is appropriate as a basic example, not a complete production policy. Its default in-memory store does not coordinate limits across multiple application instances and resets when a process restarts. Use a shared store for a multi-instance deployment. If the app runs behind a proxy, configure Express's proxy trust only for the proxies you actually control; incorrect settings can make client IP-based limits unreliable.

Add useful click analytics

The link_clicks table records one event for each redirect that reaches the insert. To count recorded clicks for a code, use an aggregate query:

SELECT code, COUNT(*) AS recorded_clicks
FROM link_clicks
WHERE code = $1
GROUP BY code;

To report activity by day, group on the timestamp. Define the reporting timezone explicitly, because the same event can fall on different calendar dates in different timezones. For example, a UTC-based daily report can use date_trunc('day', clicked_at AT TIME ZONE 'UTC').

The example deliberately stores no IP address, user agent, or referrer. Those fields can improve reporting, but they may contain personal or sensitive information and require a clear retention and privacy policy. If you add them, collect only what you need, restrict access, document retention, and consider aggregation or truncation. Also remember that click counts are not necessarily counts of unique people: crawlers, link previews, retries, and repeat visits can all generate requests.

Awaiting the database insert before redirecting gives the application a chance to record the event, but it adds database latency to each redirect. For a service where redirects must remain fast, publish click events to a durable queue and process them asynchronously. A queue introduces its own failure modes and delivery semantics, so monitor it and decide how to handle duplicates. The in-request approach shown here also has a limitation: if the process terminates before the insert completes, that click may not be recorded.

Rate limiting and abuse prevention

Rate limiting reduces automated link creation, but it does not determine whether a destination is safe. Use layered controls suited to the service:

  • Limit creation requests by account or another stable identity, with IP-based limits as one signal rather than the only one.
  • Add quotas, verification, or review for accounts that create links at unusual rates.
  • Provide a way to report abusive links and a process to disable or remove them.
  • Consider destination restrictions, reputation checks, or moderation where appropriate, while accounting for false positives and changing destinations.
  • Protect administrative actions and analytics endpoints with authentication and authorization.
  • Monitor creation failures, code-generation retries, redirect errors, and click-event write failures.

A URL parser or hostname comparison alone is not a complete security system. This tutorial's server stores a destination and redirects to it; it does not fetch the destination. If you later add preview generation or link inspection that makes outbound requests, treat that as a separate security-sensitive feature. DNS resolution, redirects, and access to private or internal network resources all require additional controls; do not assume parsing a URL makes server-side fetching safe.

Common mistakes

  • Checking uniqueness before inserting: concurrent requests can still create a collision. Enforce uniqueness in the database and retry on conflict.
  • Using sequential codes without considering enumeration: predictable identifiers can expose link volume and make guessing easier. Choose a code strategy based on your privacy and discoverability requirements.
  • Accepting any scheme: only allow schemes your redirect service intends to handle, typically HTTP and HTTPS.
  • Treating rate limits as abuse prevention by themselves: limits slow some automated behavior but do not replace reporting, review, or removal processes.
  • Logging more than needed: IP addresses, user agents, and referrers can increase privacy and security responsibilities.
  • Assuming click counts equal human visits: automated previews and repeated requests can inflate counts.
  • Using an in-memory limiter across several servers: each instance tracks its own counter. Use a shared store when limits need to apply across instances.
  • Ignoring error handling: configure application-level error handling and health checks, and ensure a database outage does not accidentally expose internal error details to clients.

Conclusion

A sound Node.js URL shortener relies on more than a redirect route. Use secure random codes backed by a unique database constraint, validate destinations, record analytics with a clear privacy policy, and combine rate limits with practical abuse response. Start with the simple database-backed flow, then introduce shared rate-limit storage or asynchronous analytics when your traffic and reliability requirements justify the added complexity.