Saltar al contenido

Cómo construir un acortador de URLs desde cero

Tutorial paso a paso de un acortador de URLs con generación de códigos, redirecciones, analítica de clics y rate limiting, del esquema a la API.

5 min de lectura
Diagrama de arquitectura de un acortador de URLs que muestra el flujo de codificación, almacenamiento y redirección

Un acortador de URLs es uno de los mejores ejercicios de diseño de sistemas porque parece simple —tomar una URL larga y devolver una corta—, pero la implementación toca hashing, diseño de bases de datos, caché, analítica y limitación de peticiones. Construir uno desde cero te enseña más sobre ingeniería de backend que la mayoría de los tutoriales.

Vamos a construir un acortador de URLs que genera códigos cortos, maneja redirecciones, registra analítica de clics y aplica límites de peticiones. El stack es Node.js con TypeScript, pero los patrones aplican a cualquier lenguaje.

Esquema de la base de datos

El modelo de datos central es directo: un mapeo de código corto a URL original, más metadatos para analítica y expiración.

sqlsql
-- Core URL mapping table
CREATE TABLE urls (
  id            BIGSERIAL PRIMARY KEY,
  short_code    VARCHAR(10) UNIQUE NOT NULL,
  original_url  TEXT NOT NULL,
  created_at    TIMESTAMPTZ DEFAULT NOW(),
  expires_at    TIMESTAMPTZ,
  click_count   BIGINT DEFAULT 0,
  creator_ip    INET,
  is_active     BOOLEAN DEFAULT TRUE
);
 
-- Index for fast lookups by short code (the hot path)
CREATE INDEX idx_urls_short_code ON urls (short_code)
  WHERE is_active = TRUE;
 
-- Click analytics table
CREATE TABLE clicks (
  id          BIGSERIAL PRIMARY KEY,
  url_id      BIGINT REFERENCES urls(id),
  clicked_at  TIMESTAMPTZ DEFAULT NOW(),
  referrer    TEXT,
  user_agent  TEXT,
  country     VARCHAR(2)
);
 
-- Index for analytics queries
CREATE INDEX idx_clicks_url_id ON clicks (url_id);
CREATE INDEX idx_clicks_clicked_at ON clicks (clicked_at);

Generación de códigos cortos

El código corto es el núcleo del sistema. Debe ser corto (6-8 caracteres), único y seguro para URLs. Hay dos enfoques principales: codificación basada en contador y generación aleatoria.

tstypescript
import crypto from 'crypto';
 
const BASE62_CHARS =
  '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
 
// Approach 1: Counter-based (deterministic, no collisions)
function encodeBase62(num: number): string {
  if (num === 0) return BASE62_CHARS[0];
 
  let result = '';
  let value = num;
 
  while (value > 0) {
    result = BASE62_CHARS[value % 62] + result;
    value = Math.floor(value / 62);
  }
 
  return result;
}
 
// Approach 2: Random generation (needs collision check)
function generateRandomCode(length: number = 7): string {
  const bytes = crypto.randomBytes(length);
  let result = '';
 
  for (let i = 0; i < length; i++) {
    result += BASE62_CHARS[bytes[i] % 62];
  }
 
  return result;
}
tstypescript
// ❌ Using MD5/SHA hash of the URL — wasteful and collision-prone
function badShortCode(url: string): string {
  const hash = crypto.createHash('md5').update(url).digest('hex');
  return hash.substring(0, 7); // Truncating a hash increases collision risk
}
 
// ✅ Using counter-based encoding — guaranteed unique, predictable
async function generateShortCode(db: Database): Promise<string> {
  // Use database sequence for guaranteed uniqueness
  const result = await db.query(
    "SELECT nextval('url_id_seq') AS id"
  );
  const id = Number(result.rows[0].id);
 
  // Offset to avoid short codes like "0", "1", "a"
  // Starting at 100_000_000 gives 7-character codes
  return encodeBase62(id + 100_000_000);
}

Endpoint de creación de URLs

El endpoint de creación valida la URL, genera un código corto, guarda el mapeo y devuelve la URL acortada.

tstypescript
import { z } from 'zod';
 
const CreateUrlSchema = z.object({
  url: z.string().url().max(2048),
  expiresIn: z.number().min(3600).max(31536000).optional(), // 1h to 1yr in seconds
  customCode: z
    .string()
    .regex(/^[a-zA-Z0-9_-]+$/)
    .min(4)
    .max(20)
    .optional(),
});
 
async function createShortUrl(
  req: Request,
  db: Database,
  config: AppConfig
): Promise<Response> {
  const body = CreateUrlSchema.parse(await req.json());
 
  // Check if custom code is already taken
  if (body.customCode) {
    const existing = await db.query(
      'SELECT id FROM urls WHERE short_code = $1',
      [body.customCode]
    );
    if (existing.rows.length > 0) {
      return new Response(
        JSON.stringify({ error: 'Custom code already in use' }),
        { status: 409 }
      );
    }
  }
 
  const shortCode = body.customCode ?? await generateShortCode(db);
  const expiresAt = body.expiresIn
    ? new Date(Date.now() + body.expiresIn * 1000)
    : null;
 
  await db.query(
    `INSERT INTO urls (short_code, original_url, expires_at, creator_ip)
     VALUES ($1, $2, $3, $4)`,
    [shortCode, body.url, expiresAt, getClientIp(req)]
  );
 
  return new Response(
    JSON.stringify({
      shortUrl: `${config.baseUrl}/${shortCode}`,
      shortCode,
      expiresAt,
    }),
    { status: 201 }
  );
}

Manejador de redirecciones

El endpoint de redirección es la ruta caliente: cada clic pasa por aquí. Debe ser rápido. Busca el código corto, registra el clic de forma asíncrona y redirige.

tstypescript
async function handleRedirect(
  req: Request,
  shortCode: string,
  db: Database,
  cache: RedisClient
): Promise<Response> {
  // Check cache first (most short URLs are accessed repeatedly)
  let originalUrl = await cache.get(`url:${shortCode}`);
 
  if (!originalUrl) {
    // Cache miss — query the database
    const result = await db.query(
      `SELECT original_url, expires_at, is_active FROM urls
       WHERE short_code = $1`,
      [shortCode]
    );
 
    if (result.rows.length === 0) {
      return new Response('Not Found', { status: 404 });
    }
 
    const row = result.rows[0];
 
    if (!row.is_active) {
      return new Response('This link has been deactivated', { status: 410 });
    }
 
    if (row.expires_at && new Date(row.expires_at) < new Date()) {
      return new Response('This link has expired', { status: 410 });
    }
 
    originalUrl = row.original_url;
 
    // Cache for 1 hour
    await cache.set(`url:${shortCode}`, originalUrl, 'EX', 3600);
  }
 
  // Record click asynchronously — do not block the redirect
  recordClick(shortCode, req, db).catch((err) =>
    console.error('Failed to record click:', err)
  );
 
  return new Response(null, {
    status: 302,
    headers: { Location: originalUrl },
  });
}
 
async function recordClick(
  shortCode: string,
  req: Request,
  db: Database
): Promise<void> {
  const userAgent = req.headers.get('user-agent') ?? '';
  const referrer = req.headers.get('referer') ?? '';
 
  await db.query(
    `INSERT INTO clicks (url_id, user_agent, referrer)
     SELECT id, $2, $3 FROM urls WHERE short_code = $1`,
    [shortCode, userAgent, referrer]
  );
 
  // Increment the denormalized counter
  await db.query(
    'UPDATE urls SET click_count = click_count + 1 WHERE short_code = $1',
    [shortCode]
  );
}

Limitación de peticiones

Sin limitación de peticiones, cualquiera puede inundar el endpoint de creación y agotar tu espacio de códigos cortos. Un limitador de ventana deslizante con Redis es simple y efectivo.

tstypescript
async function checkRateLimit(
  clientIp: string,
  cache: RedisClient,
  config: { maxRequests: number; windowSeconds: number }
): Promise<{ allowed: boolean; remaining: number; resetAt: number }> {
  const key = `ratelimit:${clientIp}`;
  const now = Date.now();
  const windowStart = now - config.windowSeconds * 1000;
 
  // Use a sorted set with timestamps as scores
  const pipeline = cache.pipeline();
  pipeline.zremrangebyscore(key, 0, windowStart); // Remove old entries
  pipeline.zadd(key, now.toString(), `${now}:${Math.random()}`);
  pipeline.zcard(key); // Count entries in window
  pipeline.expire(key, config.windowSeconds); // Auto-cleanup
 
  const results = await pipeline.exec();
  const requestCount = results?.[2]?.[1] as number;
 
  return {
    allowed: requestCount <= config.maxRequests,
    remaining: Math.max(0, config.maxRequests - requestCount),
    resetAt: Math.ceil((windowStart + config.windowSeconds * 1000) / 1000),
  };
}
tstypescript
// ❌ No rate limiting — vulnerable to abuse
app.post('/api/shorten', async (req, res) => {
  const result = await createShortUrl(req, db, config);
  return res.json(result);
});
 
// ✅ Rate limited — 100 URLs per hour per IP
app.post('/api/shorten', async (req, res) => {
  const clientIp = getClientIp(req);
  const limit = await checkRateLimit(clientIp, redis, {
    maxRequests: 100,
    windowSeconds: 3600,
  });
 
  if (!limit.allowed) {
    res.set('Retry-After', String(limit.resetAt - Math.floor(Date.now() / 1000)));
    return res.status(429).json({
      error: 'Rate limit exceeded',
      retryAfter: limit.resetAt,
    });
  }
 
  res.set('X-RateLimit-Remaining', String(limit.remaining));
  const result = await createShortUrl(req, db, config);
  return res.json(result);
});

Endpoint de analítica de clics

La analítica básica les da a los usuarios visibilidad sobre el rendimiento de sus enlaces. Agrega los datos por período de tiempo para mantener las consultas rápidas.

tstypescript
async function getUrlAnalytics(
  shortCode: string,
  db: Database
): Promise<Analytics> {
  const [urlResult, dailyClicks, topReferrers] = await Promise.all([
    db.query(
      'SELECT click_count, created_at FROM urls WHERE short_code = $1',
      [shortCode]
    ),
    db.query(
      `SELECT DATE(clicked_at) AS day, COUNT(*) AS clicks
       FROM clicks
       JOIN urls ON urls.id = clicks.url_id
       WHERE urls.short_code = $1
         AND clicked_at > NOW() - INTERVAL '30 days'
       GROUP BY DATE(clicked_at)
       ORDER BY day DESC`,
      [shortCode]
    ),
    db.query(
      `SELECT referrer, COUNT(*) AS clicks
       FROM clicks
       JOIN urls ON urls.id = clicks.url_id
       WHERE urls.short_code = $1
         AND referrer != ''
       GROUP BY referrer
       ORDER BY clicks DESC
       LIMIT 10`,
      [shortCode]
    ),
  ]);
 
  return {
    totalClicks: urlResult.rows[0]?.click_count ?? 0,
    createdAt: urlResult.rows[0]?.created_at,
    dailyClicks: dailyClicks.rows,
    topReferrers: topReferrers.rows,
  };
}

Conclusiones clave

  1. Los códigos cortos basados en contador son más simples que los aleatorios — usa una secuencia de base de datos para garantizar unicidad sin manejo de colisiones
  2. Cachea la ruta de redirección de forma agresiva — es la ruta más caliente del sistema; cada milisegundo cuenta
  3. Registra la analítica de forma asíncrona — nunca bloquees una redirección para escribir un registro de clic
  4. Limita las peticiones del endpoint de creación — sin límites, tu espacio de códigos cortos y tu base de datos son vulnerables al abuso
  5. Valida y sanitiza las URLs — rechaza entradas obviamente maliciosas o malformadas en el límite del sistema
  6. Usa consultas parametrizadas para todas las operaciones de base de datos — los acortadores de URLs manejan URLs provistas por usuarios que podrían contener cualquier cosa
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX