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

Build a URL Shortener with Analytics Using Node.js and PostgreSQL

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

October 25, 202610 min read6 topics
Build a URL Shortener with Analytics Using Node.js and PostgreSQL
Explore the details
#Node.js#URL Shortener#PostgreSQL#Express#Web Development#Analytics
October 25, 202610 min read

A URL shortener looks simple until you need to prevent duplicate codes, record clicks reliably, protect users from abusive links, and deploy it behind a proxy. This tutorial builds a small Node.js and PostgreSQL service that creates short links, redirects visitors, and stores privacy-conscious click events. It also explains what the example leaves out so you can make informed choices before putting a project on the public internet.

Key takeaways

  • Use a random short code and a database uniqueness constraint; retry if a rare collision occurs.
  • Store links and click events separately so analytics can grow without complicating redirects.
  • Validate destination URLs, but remember that accepting a URL makes your service a potential vehicle for phishing and spam.
  • Minimize analytics data: this example records a timestamp and referrer hostname, not an IP address or full referrer URL.
  • Add authentication for private analytics, shared rate-limit storage for multiple app instances, and an abuse-response process before public launch.

Architecture and project setup

The service has two main operations:

  1. POST /api/links validates a destination URL, creates a short code, and stores the link.
  2. GET /:code looks up an active link, records a click event, and sends a temporary redirect.

PostgreSQL stores both link records and click events. The app uses Express for HTTP handling and Nano ID for code generation. This is a compact portfolio-project foundation, not a complete hosted shortening platform: user accounts, ownership checks, billing, moderation tooling, and a production-grade analytics queue are outside the example.

Create a Node.js project using ECMAScript modules and install the dependencies:

npm init -y
npm install express pg nanoid express-rate-limit

Add "type": "module" to package.json. Set DATABASE_URL to your PostgreSQL connection string and PUBLIC_BASE_URL to the public origin where short links will resolve, such as https://links.example.com. Keep credentials in environment variables or your hosting provider's secret manager; do not commit them.

Design the database

A code is the public identifier, so make it unique in the database rather than relying on application logic alone. The click table refers to the code and has an index that supports per-link time-series queries.

CREATE TABLE links (
  code         varchar(8) PRIMARY KEY,
  destination_url text NOT NULL,
  created_at   timestamptz NOT NULL DEFAULT now(),
  disabled_at  timestamptz
);

CREATE TABLE click_events (
  id            bigserial PRIMARY KEY,
  short_code    varchar(8) NOT NULL REFERENCES links(code) ON DELETE CASCADE,
  clicked_at    timestamptz NOT NULL DEFAULT now(),
  referrer_host text
);

CREATE INDEX click_events_code_clicked_at_idx
  ON click_events (short_code, clicked_at DESC);

Apply schema changes with a migration before starting the app. For a larger project, use a migration tool and track applied migration versions instead of manually running SQL in each environment. The foreign key keeps click events attached to real links; deleting a link also deletes its events, so consider a retention or archival policy if you need to preserve aggregate reporting.

Build the Node.js service

The following example uses an eight-character code drawn from letters and digits. A unique constraint remains essential: random codes can collide, even when the probability is low. The insert loop retries unique-key conflicts and returns an error if it cannot find a free code after several attempts.

Destination validation accepts only HTTP and HTTPS URLs, rejects embedded credentials, and limits input length. It does not decide whether a destination is trustworthy. A URL parser can help you inspect URL components, but parsing a URL is not a substitute for abuse controls or server-side security policies.

import express from "express";
import { Pool } from "pg";
import { customAlphabet } from "nanoid";
import rateLimit from "express-rate-limit";

const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const makeCode = customAlphabet(
  "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
  8
);

if (!process.env.DATABASE_URL || !process.env.PUBLIC_BASE_URL) {
  throw new Error("DATABASE_URL and PUBLIC_BASE_URL are required");
}

const publicBase = new URL(process.env.PUBLIC_BASE_URL);
if (!["http:", "https:"].includes(publicBase.protocol)) {
  throw new Error("PUBLIC_BASE_URL must use HTTP or HTTPS");
}

// Set this to match the actual hosting topology. Trusting the wrong proxy
// configuration can make client IP-based controls unreliable.
app.set("trust proxy", 1);
app.use(express.json({ limit: "2kb" }));

const createLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 20,
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: "Too many link-creation requests. Try again later." }
});

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

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

function getReferrerHost(value) {
  if (!value) return null;

  try {
    const url = new URL(value);
    if (!["http:", "https:"].includes(url.protocol)) return null;
    return url.hostname.toLowerCase().slice(0, 253);
  } catch {
    return null;
  }
}

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

  try {
    for (let attempt = 0; attempt < 5; attempt += 1) {
      const code = makeCode();

      try {
        await pool.query(
          "INSERT INTO links (code, destination_url) VALUES ($1, $2)",
          [code, destination]
        );
        return res.status(201).json({
          code,
          shortUrl: `${publicBase.origin}/${code}`
        });
      } catch (error) {
        if (error.code === "23505") continue; // PostgreSQL unique violation
        throw error;
      }
    }

    return res.status(503).json({ error: "Could not allocate a short code." });
  } catch (error) {
    next(error);
  }
});

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

  try {
    const result = await pool.query(
      `SELECT destination_url
       FROM links
       WHERE code = $1 AND disabled_at IS NULL`,
      [code]
    );

    if (result.rowCount === 0) return res.sendStatus(404);

    const referrerHost = getReferrerHost(req.get("referer"));
    try {
      await pool.query(
        "INSERT INTO click_events (short_code, referrer_host) VALUES ($1, $2)",
        [code, referrerHost]
      );
    } catch (error) {
      // Keep redirects available if analytics storage has a temporary problem.
      // Avoid logging request headers, destination URLs, or personal data here.
      console.error("Click event insert failed", error.code ?? "unknown");
    }

    return res.redirect(302, result.rows[0].destination_url);
  } catch (error) {
    next(error);
  }
});

app.use((error, req, res, next) => {
  if (res.headersSent) return next(error);
  if (error.type === "entity.parse.failed") {
    return res.status(400).json({ error: "Request body must contain valid JSON." });
  }
  console.error("Request failed", error.code ?? "unknown");
  return res.status(500).json({ error: "Internal server error." });
});

const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => console.log(`URL shortener listening on ${port}`));

The limiter shown uses its default in-memory store. That is convenient for local development and a single process, but it does not share counts across multiple instances and resets when the process restarts. Use a compatible shared store, such as Redis-backed storage, when deploying multiple instances. Configure Express's trusted proxy setting for your actual provider and network path; blindly trusting forwarded headers can undermine IP-based controls.

Try the main flows

Start the service after applying the schema, then create a link:

curl -i -X POST http://localhost:3000/api/links \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://example.com/docs?from=demo"}'

Use the returned shortUrl in a browser or request it with redirects disabled to inspect the 302 response. Also test malformed JSON, unsupported schemes such as javascript:, an unknown code, a disabled code, and repeated creation requests that exceed the limiter. In a test database, verify that a valid redirect adds one click event and that a database failure in the analytics insert still allows the redirect. These checks exercise the behavior shown here; they are not a substitute for automated integration tests in your own deployment.

Query click analytics

Because click events are separate rows, simple reports can use SQL without changing the redirect handler. For daily totals and referrer hosts over a selected period:

SELECT date_trunc('day', clicked_at) AS day,
       referrer_host,
       count(*) AS clicks
FROM click_events
WHERE short_code = $1
  AND clicked_at >= $2
  AND clicked_at < $3
GROUP BY day, referrer_host
ORDER BY day DESC, clicks DESC;

Parameterize all query values, as shown by $1, $2, and $3; do not interpolate user input into SQL. Do not expose this query through a public endpoint without authorization. If links belong to users, associate each link with an authenticated owner and check ownership before returning detailed analytics. Unlisted codes are not an access-control mechanism.

Privacy, abuse prevention, and operational trade-offs

Collect only what you need

The example stores the click time and referrer hostname, not the full referrer URL, IP address, or user-agent string. Referrer headers can be absent or restricted by browser policy, so analytics will be incomplete. A hostname can still reveal information about a visitor's browsing context. Document what you collect, set a retention period, restrict database access, and provide the disclosures required for your service and jurisdiction.

Rate limiting may process an IP address to identify request sources, even when the application does not save that address in click events. Account for that processing in your privacy decisions. If you add IP-based fraud detection or device analytics, define a purpose, retention window, access policy, and deletion approach before collecting the data.

Treat destinations as user-generated content

A shortener can be abused to distribute phishing, malware, spam, or misleading redirects. At minimum, add creation limits, monitoring, a way to report links, and a process for disabling harmful destinations. The disabled_at field supports a basic administrative disable action, but the example intentionally does not expose an unauthenticated endpoint for it. Protect moderation controls with authentication and authorization, audit administrative changes, and consider account-level quotas and reputation checks.

Only allowing HTTP and HTTPS blocks some unsafe schemes; it does not establish that a destination is safe. Avoid claiming that a parser or a hostname check can reliably determine whether a link is benign. A URL shortener that only redirects does not fetch the destination itself, so SSRF protections are not automatically relevant to this redirect code. If you add previews, screenshots, metadata extraction, or any server-side URL fetching, that introduces a separate SSRF threat model requiring dedicated network egress controls, redirect handling, DNS and IP checks, and other defenses.

Choose an analytics reliability model

This implementation waits for the click insert before redirecting. That makes a successful database write part of the redirect's latency, but catches insert failures and still redirects. Under heavy traffic, a slow database can make redirects feel slower. A queue or stream can decouple analytics from the request, but introduces delivery delays, retries, duplicate-event handling, and more infrastructure. If you switch to asynchronous delivery, define whether losing some events during outages is acceptable and make consumers idempotent where needed.

For high-volume services, raw event tables can grow quickly. Set retention and cleanup policies, partition or archive data when appropriate, and consider pre-aggregated counts. Do not add a total-click counter without considering concurrent updates and reconciliation: it can become a write hotspot and may diverge from the event table.

Common mistakes

  • Generating codes without a unique constraint: application-level checks alone can race. Let the database enforce uniqueness and retry collisions.
  • Using sequential identifiers: predictable codes make links easier to enumerate. Random codes reduce guessing, but do not replace authorization for analytics or private links.
  • Using permanent redirects by default: a 301 may be cached by clients. A 302 is a practical starting point when destinations can change; choose redirect status codes based on the behavior your product promises.
  • Storing raw request headers: full referrers and user-agent strings can expose more information than needed. Extract only fields with a clear analytics purpose.
  • Trusting proxy headers without configuration: incorrect proxy trust can break rate-limit identity and make client IP values spoofable.
  • Treating URL validation as content moderation: syntactically valid URLs may still be deceptive or harmful. Provide reporting and a reliable disable process.
  • Publishing a stats endpoint without ownership checks: short codes should not act as authorization tokens for sensitive analytics.

Deployment checklist

  1. Provision PostgreSQL and apply schema migrations before releasing the app.
  2. Set DATABASE_URL, PUBLIC_BASE_URL, and PORT through deployment configuration; use the provider's recommended TLS settings for database connections.
  3. Configure the correct trusted proxy count or trusted proxy ranges, then verify rate limiting uses the expected client identity.
  4. Replace the in-memory rate-limit store if the service runs on multiple instances.
  5. Add a managed shared rate limiter, monitoring for creation spikes and failed database writes, and an authenticated moderation workflow.
  6. Add authentication and ownership checks before exposing per-link analytics. Test redirects, invalid inputs, limits, and outage behavior in a staging environment.
  7. Set database backups, retention rules for click events, and a process for responding to abuse reports.

Conclusion

A useful URL shortener is more than a random code followed by a redirect. Database-enforced uniqueness, careful destination validation, privacy-conscious click events, rate limiting, and a clear abuse-response process are all part of the design. The Node.js and PostgreSQL example gives you a workable starting point; choose the analytics reliability and operational controls that fit your expected traffic and users before making it public.