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.

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.
-- 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.
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;
}// ❌ 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.
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.
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.
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),
};
}// ❌ 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.
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
- 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
- Cachea la ruta de redirección de forma agresiva — es la ruta más caliente del sistema; cada milisegundo cuenta
- Registra la analítica de forma asíncrona — nunca bloquees una redirección para escribir un registro de clic
- 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
- Valida y sanitiza las URLs — rechaza entradas obviamente maliciosas o malformadas en el límite del sistema
- 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


