Einen URL-Shortener von Grund auf selbst bauen
Schritt-für-Schritt zum URL-Shortener mit Kurzcode-Generierung, Redirect-Handling, Klick-Analytik und Rate-Limiting — vom Datenbankschema bis zur API.

Ein URL-Shortener ist eine der besten Systemdesign-Übungen, weil er simpel aussieht — eine lange URL rein, eine kurze raus —, die Implementierung aber Hashing, Datenbankdesign, Caching, Analytik und Rate-Limiting berührt. Wer einen von Grund auf baut, lernt mehr über Backend-Engineering als in den meisten Tutorials.
Wir bauen einen URL-Shortener, der Kurzcodes generiert, Redirects verarbeitet, Klick-Analytik erfasst und Rate-Limits durchsetzt. Der Stack ist Node.js mit TypeScript, aber die Muster gelten für jede Sprache.
Datenbankschema
Das zentrale Datenmodell ist unkompliziert: ein Mapping von Kurzcode zur Original-URL, plus Metadaten für Analytik und Ablaufdatum.
-- 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);Generierung der Kurzcodes
Der Kurzcode ist der Kern des Systems. Er muss kurz (6-8 Zeichen), eindeutig und URL-sicher sein. Es gibt zwei Hauptansätze: zählerbasierte Kodierung und Zufallsgenerierung.
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 zur URL-Erstellung
Der Erstellungs-Endpoint validiert die URL, generiert einen Kurzcode, speichert das Mapping und gibt die gekürzte URL zurück.
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 }
);
}Redirect-Handler
Der Redirect-Endpoint ist der Hot Path — jeder Klick läuft hier durch. Er muss schnell sein. Kurzcode nachschlagen, den Klick asynchron erfassen und weiterleiten.
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]
);
}Rate-Limiting
Ohne Rate-Limiting kann jeder den Erstellungs-Endpoint fluten und deinen Kurzcode-Raum erschöpfen. Ein Sliding-Window-Rate-Limiter mit Redis ist einfach und effektiv.
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 für Klick-Analytik
Basis-Analytik gibt Nutzern Einblick in die Performance ihrer Links. Aggregiere die Daten nach Zeiträumen, damit die Abfragen schnell bleiben.
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,
};
}Die wichtigsten Erkenntnisse
- Zählerbasierte Kurzcodes sind einfacher als Zufallscodes — nutze eine Datenbank-Sequenz, um Eindeutigkeit ohne Kollisionsbehandlung zu garantieren
- Cache den Redirect-Pfad aggressiv — das ist der heißeste Pfad im System; jede Millisekunde zählt
- Erfasse Analytik asynchron — blockiere niemals einen Redirect, um einen Klick-Eintrag zu schreiben
- Rate-Limite den Erstellungs-Endpoint — ohne Limits sind dein Kurzcode-Raum und deine Datenbank anfällig für Missbrauch
- Validiere und bereinige URLs — weise offensichtlich bösartige oder fehlerhafte Eingaben an der Systemgrenze ab
- Verwende parametrisierte Queries für alle Datenbankoperationen — URL-Shortener verarbeiten nutzerseitige URLs, die alles Mögliche enthalten können


