Build a URL Shortener: SEO & Link Previews

Khi ai đó share link YouTube trên Slack, cả channel sáng lên — thumbnail, title, description, thậm chí cả thời lượng video. Bạn biết ngay link đó về cái gì, và khả năng click cao hơn nhiều. Giờ thử so sánh với việc share một shortened URL: https://short.ly/x7Kq9. Chẳng có gì. Chỉ một chuỗi ký tự bí ẩn. Không context, không preview, không trust.
Khoảng cách giữa "short link bí ẩn" và "rich preview card đầy thông tin" chính là thứ chúng ta sẽ lấp đầy trong bài này. Link previews là sự khác biệt giữa một URL được click và một URL bị bỏ qua. Đồng thời đó còn là trust signal — user hoàn toàn có lý khi nghi ngờ những short URL có thể dẫn đến bất kỳ đâu.
Đây là bài cuối cùng trong series URL shortener. Chúng ta đã build xong API, database, cache, auth system, frontend, tests và deployment pipeline. Giờ là lúc thêm finishing touch khiến short links của chúng ta trông professional — rich link previews hoạt động trên mọi social platform.
Thời gian: 2-3 giờ
Prerequisites: Phase 13: React Admin Dashboard UI
Những gì chúng ta sẽ build trong bài này:
✅ OG meta tag scraping từ destination URLs
✅ Link preview metadata storage và caching
✅ Preview page cho short URLs (hiển thị thông tin destination trước khi redirect)
✅ Dynamic OG image generation cho short links
✅ Bot-aware redirect handling (serve meta tags cho crawlers, redirect cho humans)
✅ Social platform compatibility (Facebook, Twitter/X, Slack, LinkedIn, Discord)
Cách Link Previews Hoạt Động
Khi bạn paste URL vào Slack, Twitter, Facebook, hay bất kỳ messaging platform nào, một điều thú vị xảy ra phía sau. Platform không chỉ hiển thị raw URL — nó gửi một bot (crawler) để fetch trang và extract metadata từ các HTML <meta> tags cụ thể.
Các tags này tuân theo Open Graph protocol, ban đầu được Facebook tạo ra, và giờ được gần như mọi platform hỗ trợ link previews adopt.
Đây là các tags mà platforms tìm kiếm:
<!-- Open Graph (Facebook, LinkedIn, Slack, Discord) -->
<meta property="og:title" content="How to Learn TypeScript in 2026" />
<meta property="og:description" content="A practical guide to mastering TypeScript from scratch." />
<meta property="og:image" content="https://example.com/images/typescript-guide.png" />
<meta property="og:url" content="https://example.com/typescript-guide" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Dev Blog" />
<!-- Twitter/X Cards -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="How to Learn TypeScript in 2026" />
<meta name="twitter:description" content="A practical guide to mastering TypeScript from scratch." />
<meta name="twitter:image" content="https://example.com/images/typescript-guide.png" />Flow diễn ra như thế này:
Không có OG tags, platforms hoặc không hiển thị gì hoặc chỉ show raw URL. Có chúng, short links trở thành rich, clickable preview cards.
Bài Toán Bot vs Human
Đây là challenge cốt lõi: khi browser truy cập https://short.ly/x7Kq9, chúng ta muốn redirect họ tới destination. Nhưng khi Slack bot truy cập cùng URL đó, chúng ta muốn serve một HTML page với OG meta tags để platform render preview.
Chúng ta cần handle cùng một URL khác nhau tùy theo ai đang truy cập:
Detect Social Media Bots
Mỗi social platform nhận diện crawler của mình bằng User-Agent string riêng. Đây là cách chúng ta detect chúng:
// src/utils/bot-detection.ts
const BOT_USER_AGENTS = [
'facebookexternalhit',
'Twitterbot',
'Slackbot',
'LinkedInBot',
'Discordbot',
'WhatsApp',
'TelegramBot',
'Googlebot',
'bingbot',
'Pinterestbot',
'redditbot',
'Applebot',
'Embedly',
];
export function isSocialBot(userAgent: string): boolean {
if (!userAgent) return false;
const ua = userAgent.toLowerCase();
return BOT_USER_AGENTS.some(bot => ua.includes(bot.toLowerCase()));
}
export function identifyBot(userAgent: string): string | null {
if (!userAgent) return null;
const ua = userAgent.toLowerCase();
const match = BOT_USER_AGENTS.find(bot => ua.includes(bot.toLowerCase()));
return match || null;
}Tại sao include Googlebot? Search engines cũng dùng OG tags và structured metadata cho rich search results. Serve cho chúng proper meta tags cải thiện SEO của short links.
Prisma Schema — Link Metadata
Chúng ta cần store scraped metadata từ destination URLs. Thêm model mới vào Prisma schema:
// prisma/schema.prisma
model UrlMetadata {
id String @id @default(uuid())
urlId String @unique
url Url @relation(fields: [urlId], references: [id], onDelete: Cascade)
ogTitle String?
ogDescription String?
ogImage String?
ogSiteName String?
favicon String?
scrapedAt DateTime @default(now())
expiresAt DateTime // Refresh metadata định kỳ
@@index([urlId])
@@index([expiresAt])
}
// Update Url model hiện tại để thêm relation
model Url {
id String @id @default(uuid())
shortCode String @unique
originalUrl String
// ... các fields hiện có ...
metadata UrlMetadata?
// ... các relations hiện có ...
}Chạy migration:
npx prisma migrate dev --name add-url-metadataCác quyết định design quan trọng:
@uniquetrênurlId— mỗi URL chỉ có một metadata record (quan hệ 1:1)expiresAtindex — query hiệu quả cho metadata cần refreshonDelete: Cascade— khi URL bị xóa, metadata cũng tự động dọn dẹp- Tất cả metadata fields đều optional — scraping có thể fail hoặc trả về partial data
OG Meta Scraper Service
Giờ build service để fetch destination URL và extract OG meta tags. Chúng ta dùng cheerio để parse HTML:
npm install cheerio
npm install -D @types/cheerio// src/services/metadata.service.ts
import * as cheerio from 'cheerio';
import { logger } from '../utils/logger';
export interface LinkMetadata {
ogTitle: string | null;
ogDescription: string | null;
ogImage: string | null;
ogSiteName: string | null;
favicon: string | null;
}
export async function scrapeMetadata(url: string): Promise<LinkMetadata> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(url, {
signal: controller.signal,
headers: {
'User-Agent': 'ShortLinkBot/1.0 (metadata preview)',
'Accept': 'text/html',
'Accept-Language': 'en-US,en;q=0.9',
},
redirect: 'follow',
});
if (!response.ok) {
logger.warn({ url, status: response.status }, 'Failed to fetch URL for scraping');
return emptyMetadata();
}
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('text/html')) {
logger.info({ url, contentType }, 'Non-HTML content, skipping scrape');
return emptyMetadata();
}
// Chỉ đọc 100KB đầu tiên để tránh memory issues với trang lớn
const reader = response.body?.getReader();
if (!reader) return emptyMetadata();
let html = '';
const decoder = new TextDecoder();
const MAX_BYTES = 100 * 1024; // 100KB
let totalBytes = 0;
while (totalBytes < MAX_BYTES) {
const { done, value } = await reader.read();
if (done) break;
html += decoder.decode(value, { stream: true });
totalBytes += value.length;
}
reader.cancel();
const $ = cheerio.load(html);
return {
ogTitle:
$('meta[property="og:title"]').attr('content') ||
$('meta[name="twitter:title"]').attr('content') ||
$('title').text().trim() ||
null,
ogDescription:
$('meta[property="og:description"]').attr('content') ||
$('meta[name="twitter:description"]').attr('content') ||
$('meta[name="description"]').attr('content') ||
null,
ogImage:
$('meta[property="og:image"]').attr('content') ||
$('meta[name="twitter:image"]').attr('content') ||
null,
ogSiteName:
$('meta[property="og:site_name"]').attr('content') ||
null,
favicon:
$('link[rel="icon"]').attr('href') ||
$('link[rel="shortcut icon"]').attr('href') ||
$('link[rel="apple-touch-icon"]').attr('href') ||
null,
};
} catch (err) {
if ((err as Error).name === 'AbortError') {
logger.warn({ url }, 'Metadata scrape timed out after 5s');
} else {
logger.warn({ url, err }, 'Metadata scrape failed');
}
return emptyMetadata();
} finally {
clearTimeout(timeout);
}
}
function emptyMetadata(): LinkMetadata {
return {
ogTitle: null,
ogDescription: null,
ogImage: null,
ogSiteName: null,
favicon: null,
};
}Resolve Relative URLs
OG images và favicons thường được khai báo dưới dạng relative paths. Chúng ta cần resolve chúng thành absolute URLs:
// src/utils/url-helpers.ts
export function resolveUrl(baseUrl: string, path: string | null): string | null {
if (!path) return null;
// Đã là absolute rồi
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
// Protocol-relative
if (path.startsWith('//')) {
return `https:${path}`;
}
try {
const base = new URL(baseUrl);
return new URL(path, base.origin).toString();
} catch {
return null;
}
}Update scraper để resolve URLs:
// Sau khi scrape, resolve relative URLs
const metadata = await scrapeRawMetadata(url);
return {
...metadata,
ogImage: resolveUrl(url, metadata.ogImage),
favicon: resolveUrl(url, metadata.favicon),
};Phòng Chống SSRF
Trước khi scrape bất kỳ URL nào, chúng ta phải ngăn Server-Side Request Forgery (SSRF) attacks. User ác ý có thể submit http://169.254.169.254/latest/meta-data/ (AWS metadata endpoint) hoặc http://localhost:3000/admin làm destination URL, và scraper sẽ ngoan ngoãn fetch nó.
// src/utils/ssrf-prevention.ts
import dns from 'dns/promises';
import { logger } from './logger';
const PRIVATE_IP_RANGES = [
/^127\./, // Loopback
/^10\./, // Class A private
/^172\.(1[6-9]|2\d|3[01])\./, // Class B private
/^192\.168\./, // Class C private
/^169\.254\./, // Link-local
/^0\./, // Current network
/^fc00:/i, // IPv6 unique local
/^fe80:/i, // IPv6 link-local
/^::1$/, // IPv6 loopback
];
export function isPrivateIP(ip: string): boolean {
return PRIVATE_IP_RANGES.some(range => range.test(ip));
}
const BLOCKED_HOSTNAMES = [
'localhost',
'metadata.google.internal',
'metadata.google',
];
export async function validateScrapeTarget(url: string): Promise<boolean> {
try {
const { hostname } = new URL(url);
// Block known dangerous hostnames
if (BLOCKED_HOSTNAMES.includes(hostname.toLowerCase())) {
logger.warn({ url }, 'Blocked scrape attempt — dangerous hostname');
return false;
}
// Block IP addresses dùng trực tiếp trong URLs
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) && isPrivateIP(hostname)) {
logger.warn({ url }, 'Blocked scrape attempt — private IP in URL');
return false;
}
// Resolve hostname và check tất cả IPs
const addresses = await dns.resolve4(hostname).catch(() => []);
const addresses6 = await dns.resolve6(hostname).catch(() => []);
const allAddresses = [...addresses, ...addresses6];
if (allAddresses.some(ip => isPrivateIP(ip))) {
logger.warn({ url, addresses: allAddresses }, 'Blocked SSRF attempt — resolves to private IP');
return false;
}
return true;
} catch (err) {
logger.warn({ url, err }, 'Failed to validate scrape target');
return false;
}
}Giờ wrap scraper với SSRF protection:
// src/services/metadata.service.ts
export async function safeScrapeMetadata(url: string): Promise<LinkMetadata> {
const isSafe = await validateScrapeTarget(url);
if (!isSafe) {
return emptyMetadata();
}
return scrapeMetadata(url);
}Scraping Khi Tạo URL
Khi user rút gọn URL, chúng ta muốn scrape metadata trong background — fire-and-forget. Pattern này giữ cho API response nhanh trong khi populate preview data bất đồng bộ.
// src/services/metadata.service.ts
import { prisma } from '../db';
import { redis } from '../cache';
const METADATA_TTL = 24 * 60 * 60; // 24 giờ tính bằng giây
const METADATA_REFRESH_HOURS = 24;
export async function scrapeAndStoreMetadata(
urlId: string,
originalUrl: string
): Promise<void> {
const metadata = await safeScrapeMetadata(originalUrl);
// Không store nếu chẳng có gì hữu ích
if (!metadata.ogTitle && !metadata.ogDescription && !metadata.ogImage) {
logger.info({ urlId }, 'No useful metadata found, skipping storage');
return;
}
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + METADATA_REFRESH_HOURS);
// Store vào database
await prisma.urlMetadata.upsert({
where: { urlId },
create: {
urlId,
ogTitle: metadata.ogTitle,
ogDescription: metadata.ogDescription,
ogImage: metadata.ogImage,
ogSiteName: metadata.ogSiteName,
favicon: metadata.favicon,
expiresAt,
},
update: {
ogTitle: metadata.ogTitle,
ogDescription: metadata.ogDescription,
ogImage: metadata.ogImage,
ogSiteName: metadata.ogSiteName,
favicon: metadata.favicon,
scrapedAt: new Date(),
expiresAt,
},
});
// Cache trong Redis
const cacheKey = `metadata:${urlId}`;
await redis.setex(cacheKey, METADATA_TTL, JSON.stringify(metadata));
logger.info({ urlId, title: metadata.ogTitle }, 'Metadata scraped and stored');
}Giờ integrate vào flow tạo URL:
// src/controllers/url.controller.ts
export async function shortenUrl(req: Request, res: Response) {
const { url: originalUrl, customAlias, expiresAt } = req.body;
// ... validation, short code generation, database insert ...
const newUrl = await prisma.url.create({
data: {
shortCode,
originalUrl,
userId: req.user?.id,
expiresAt: expiresAt ? new Date(expiresAt) : null,
},
});
// Fire-and-forget: scrape metadata trong background
scrapeAndStoreMetadata(newUrl.id, originalUrl).catch(err => {
logger.warn({ err, urlId: newUrl.id }, 'Background metadata scrape failed');
});
// Return ngay lập tức — không chờ scraping
return res.status(201).json({
shortCode: newUrl.shortCode,
shortUrl: `${BASE_URL}/${newUrl.shortCode}`,
originalUrl: newUrl.originalUrl,
expiresAt: newUrl.expiresAt,
});
}Pattern quan trọng ở đây là fire-and-forget. Chúng ta gọi scrapeAndStoreMetadata() nhưng không await nó. API response trả về ngay lập tức, còn scraping chạy trong background. Nếu fail, log warning nhưng URL vẫn hoạt động — chỉ là không có rich previews thôi.
Chiến Lược Caching Metadata
Chúng ta cần lookup path nhanh cho metadata vì nó được gọi mỗi khi bot visit. Caching layer theo cùng cache-aside pattern đã dùng cho URL lookups:
// src/services/metadata.service.ts
export async function getMetadata(urlId: string): Promise<LinkMetadata | null> {
// 1. Check Redis cache trước
const cacheKey = `metadata:${urlId}`;
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached) as LinkMetadata;
}
// 2. Fall back sang database
const metadata = await prisma.urlMetadata.findUnique({
where: { urlId },
});
if (!metadata) {
return null;
}
// 3. Check xem metadata có cần refresh không
if (new Date() > metadata.expiresAt) {
// Trigger background refresh, nhưng trả stale data trước
const url = await prisma.url.findUnique({
where: { id: urlId },
select: { originalUrl: true },
});
if (url) {
scrapeAndStoreMetadata(urlId, url.originalUrl).catch(err => {
logger.warn({ err, urlId }, 'Background metadata refresh failed');
});
}
}
// 4. Populate cache từ database result
const linkMetadata: LinkMetadata = {
ogTitle: metadata.ogTitle,
ogDescription: metadata.ogDescription,
ogImage: metadata.ogImage,
ogSiteName: metadata.ogSiteName,
favicon: metadata.favicon,
};
await redis.setex(cacheKey, METADATA_TTL, JSON.stringify(linkMetadata));
return linkMetadata;
}Caching flow:
Các quyết định design quan trọng:
- Stale-while-revalidate — trả stale metadata ngay lập tức trong khi refresh trong background
- 24-hour TTL — metadata refresh hàng ngày để bắt kịp thay đổi trên destination pages
- Graceful degradation — nếu thiếu metadata, dùng fallback values (raw URL)
Cập Nhật Redirect Handler — Bot Detection
Giờ update redirect route để handle bots khác với humans:
// src/routes/redirect.ts
import { Request, Response } from 'express';
import { isSocialBot, identifyBot } from '../utils/bot-detection';
import { getMetadata } from '../services/metadata.service';
import { renderPreviewHtml } from '../templates/preview-html';
import { renderPreviewPage } from '../templates/preview-page';
import { recordClick } from '../services/analytics.service';
import { resolveShortCode } from '../services/url.service';
import { logger } from '../utils/logger';
export async function handleRedirect(req: Request, res: Response) {
const { code } = req.params;
const url = await resolveShortCode(code);
if (!url) {
return res.status(404).json({ error: 'Short URL not found' });
}
if (!url.isActive) {
return res.status(410).json({ error: 'This short URL has been deactivated' });
}
if (url.expiresAt && new Date() > url.expiresAt) {
return res.status(410).json({ error: 'This short URL has expired' });
}
const userAgent = req.headers['user-agent'] || '';
// Path 1: Social media bot — serve OG tags
if (isSocialBot(userAgent)) {
const botName = identifyBot(userAgent);
logger.info({ code, botName }, 'Serving OG preview to bot');
const metadata = await getMetadata(url.id);
return res.send(renderPreviewHtml(url, metadata));
}
// Path 2: Human yêu cầu preview page
if (req.query.preview === 'true' || code.endsWith('+')) {
const actualCode = code.endsWith('+') ? code.slice(0, -1) : code;
const metadata = await getMetadata(url.id);
return res.send(renderPreviewPage(url, metadata));
}
// Path 3: Human redirect bình thường
recordClick(url.id, req).catch(err => {
logger.warn({ err, urlId: url.id }, 'Failed to record click');
});
return res.redirect(302, url.originalUrl);
}Chú ý chúng ta hỗ trợ ba modes:
- Bot visit — detect qua User-Agent, serve HTML với OG meta tags
- Preview mode — human thêm
?preview=truehoặc+để xem thông tin destination trước khi redirect - Normal redirect — 302 redirect tiêu chuẩn cho human visitors
Preview HTML Template (Cho Bots)
Đây là HTML page tối giản serve cho social media crawlers. Nó chứa OG meta tags và <meta http-equiv="refresh"> fallback phòng trường hợp human vô tình truy cập:
// src/templates/preview-html.ts
import { escapeHtml } from '../utils/html';
const BASE_URL = process.env.BASE_URL || 'https://short.ly';
interface UrlRecord {
shortCode: string;
originalUrl: string;
}
interface LinkMetadata {
ogTitle: string | null;
ogDescription: string | null;
ogImage: string | null;
ogSiteName: string | null;
favicon: string | null;
}
export function renderPreviewHtml(
url: UrlRecord,
metadata: LinkMetadata | null
): string {
const title = metadata?.ogTitle || extractDomain(url.originalUrl);
const description =
metadata?.ogDescription || `Shortened link to ${extractDomain(url.originalUrl)}`;
const image = metadata?.ogImage || `${BASE_URL}/api/og/${url.shortCode}`;
const siteName = metadata?.ogSiteName || extractDomain(url.originalUrl);
const shortUrl = `${BASE_URL}/${url.shortCode}`;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Open Graph -->
<meta property="og:title" content="${escapeHtml(title)}" />
<meta property="og:description" content="${escapeHtml(description)}" />
<meta property="og:image" content="${escapeHtml(image)}" />
<meta property="og:url" content="${escapeHtml(shortUrl)}" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="${escapeHtml(siteName)}" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="${escapeHtml(title)}" />
<meta name="twitter:description" content="${escapeHtml(description)}" />
<meta name="twitter:image" content="${escapeHtml(image)}" />
<!-- Fallback redirect cho humans -->
<meta http-equiv="refresh" content="0;url=${escapeHtml(url.originalUrl)}" />
<title>${escapeHtml(title)}</title>
</head>
<body>
<p>Redirecting to <a href="${escapeHtml(url.originalUrl)}">${escapeHtml(url.originalUrl)}</a>...</p>
</body>
</html>`;
}
function extractDomain(url: string): string {
try {
return new URL(url).hostname;
} catch {
return url;
}
}HTML Escaping Để Chống XSS
Phần này cực kỳ quan trọng — metadata scraped từ destination URL là untrusted input. Trang ác ý có thể set OG title thành "><script>alert('xss')</script>, và nếu inject raw vào HTML, đó là lỗ hổng XSS.
// src/utils/html.ts
const HTML_ESCAPE_MAP: Record<string, string> = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
};
export function escapeHtml(str: string): string {
return str.replace(/[&<>"']/g, char => HTML_ESCAPE_MAP[char] || char);
}Dynamic OG Image Generation
Khi destination URL không có OG image riêng, chúng ta generate một cái tự động. Điều này đảm bảo mọi short link đều có rich preview, kể cả khi destination page cấu hình kém.
Dùng satori (của Vercel) để convert JSX-like template sang SVG, rồi sharp để convert sang PNG:
npm install satori sharp
npm install -D @types/sharp// src/routes/og-image.ts
import { Request, Response } from 'express';
import satori from 'satori';
import sharp from 'sharp';
import fs from 'fs';
import path from 'path';
import { resolveShortCode } from '../services/url.service';
import { getMetadata } from '../services/metadata.service';
import { redis } from '../cache';
import { logger } from '../utils/logger';
const BASE_URL = process.env.BASE_URL || 'https://short.ly';
// Load font một lần khi startup
const interFont = fs.readFileSync(
path.join(__dirname, '../assets/fonts/Inter-Bold.ttf')
);
const interRegular = fs.readFileSync(
path.join(__dirname, '../assets/fonts/Inter-Regular.ttf')
);
export async function generateOgImage(req: Request, res: Response) {
const { shortCode } = req.params;
// Check cache trước
const cacheKey = `og-image:${shortCode}`;
const cached = await redis.getBuffer(cacheKey);
if (cached) {
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'public, max-age=86400');
return res.send(cached);
}
const url = await resolveShortCode(shortCode);
if (!url) {
return res.status(404).json({ error: 'Short URL not found' });
}
const metadata = await getMetadata(url.id);
const title = metadata?.ogTitle || 'Shortened Link';
const description =
metadata?.ogDescription || url.originalUrl;
const domain = extractDomain(url.originalUrl);
try {
const svg = await satori(
{
type: 'div',
props: {
style: {
width: '1200px',
height: '630px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
padding: '60px',
background: 'linear-gradient(135deg, #0f172a 0%, #1e3a5f 50%, #0f172a 100%)',
color: '#ffffff',
fontFamily: 'Inter',
},
children: [
// Top: short URL badge
{
type: 'div',
props: {
style: {
display: 'flex',
alignItems: 'center',
gap: '12px',
},
children: [
{
type: 'div',
props: {
style: {
background: 'rgba(59, 130, 246, 0.3)',
borderRadius: '9999px',
padding: '8px 20px',
fontSize: '20px',
color: '#93c5fd',
},
children: `${BASE_URL}/${shortCode}`,
},
},
],
},
},
// Middle: title và description
{
type: 'div',
props: {
style: {
display: 'flex',
flexDirection: 'column',
gap: '16px',
},
children: [
{
type: 'div',
props: {
style: {
fontSize: '48px',
fontWeight: 700,
lineHeight: 1.2,
maxHeight: '180px',
overflow: 'hidden',
},
children: truncate(title, 80),
},
},
{
type: 'div',
props: {
style: {
fontSize: '24px',
color: '#94a3b8',
lineHeight: 1.4,
},
children: truncate(description, 120),
},
},
],
},
},
// Bottom: branding
{
type: 'div',
props: {
style: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: '20px',
color: '#64748b',
},
children: [
{ type: 'span', props: { children: `Destination: ${domain}` } },
{ type: 'span', props: { children: 'short.ly' } },
],
},
},
],
},
},
{
width: 1200,
height: 630,
fonts: [
{ name: 'Inter', data: interFont, weight: 700, style: 'normal' },
{ name: 'Inter', data: interRegular, weight: 400, style: 'normal' },
],
}
);
const png = await sharp(Buffer.from(svg)).png({ quality: 90 }).toBuffer();
// Cache 24 giờ
await redis.setex(cacheKey, 86400, png);
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'public, max-age=86400, s-maxage=86400');
return res.send(png);
} catch (err) {
logger.error({ err, shortCode }, 'Failed to generate OG image');
return res.status(500).json({ error: 'Failed to generate image' });
}
}
function truncate(str: string, maxLen: number): string {
if (str.length <= maxLen) return str;
return str.slice(0, maxLen - 3) + '...';
}
function extractDomain(url: string): string {
try {
return new URL(url).hostname;
} catch {
return url;
}
}Register route:
// src/routes/index.ts
import { generateOgImage } from './og-image';
router.get('/api/og/:shortCode', generateOgImage);Giờ mọi short link đều có OG image được đảm bảo. Nếu destination có OG image riêng, dùng cái đó. Nếu không, generate tự động với title, description và branding.
Preview Page Cho Humans
Đôi khi user muốn xem short link dẫn đến đâu trước khi click. Chúng ta hỗ trợ với preview page truy cập tại short.ly/abc123+ hoặc short.ly/abc123?preview=true.
// src/templates/preview-page.ts
import { escapeHtml } from '../utils/html';
const BASE_URL = process.env.BASE_URL || 'https://short.ly';
interface UrlRecord {
shortCode: string;
originalUrl: string;
clickCount: number;
createdAt: Date;
}
interface LinkMetadata {
ogTitle: string | null;
ogDescription: string | null;
ogImage: string | null;
ogSiteName: string | null;
favicon: string | null;
}
export function renderPreviewPage(
url: UrlRecord,
metadata: LinkMetadata | null
): string {
const title = metadata?.ogTitle || extractDomain(url.originalUrl);
const description = metadata?.ogDescription || 'No description available';
const image = metadata?.ogImage;
const favicon = metadata?.favicon;
const domain = extractDomain(url.originalUrl);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Link Preview — ${escapeHtml(title)}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, sans-serif;
background: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.card {
background: #1e293b;
border: 1px solid #334155;
border-radius: 16px;
max-width: 600px;
width: 100%;
overflow: hidden;
}
.card-image {
width: 100%;
height: 300px;
object-fit: cover;
border-bottom: 1px solid #334155;
}
.card-body { padding: 32px; }
.card-site {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
font-size: 14px;
color: #94a3b8;
}
.card-site img {
width: 16px;
height: 16px;
border-radius: 4px;
}
.card-title {
font-size: 24px;
font-weight: 700;
margin-bottom: 8px;
line-height: 1.3;
}
.card-desc {
font-size: 16px;
color: #94a3b8;
line-height: 1.5;
margin-bottom: 24px;
}
.card-meta {
display: flex;
gap: 24px;
margin-bottom: 24px;
font-size: 14px;
color: #64748b;
}
.destination {
background: #0f172a;
border: 1px solid #334155;
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 24px;
font-size: 14px;
word-break: break-all;
color: #94a3b8;
}
.destination-label {
font-size: 12px;
text-transform: uppercase;
color: #64748b;
margin-bottom: 4px;
letter-spacing: 0.05em;
}
.btn {
display: block;
width: 100%;
padding: 14px;
background: #3b82f6;
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
text-align: center;
text-decoration: none;
transition: background 0.2s;
}
.btn:hover { background: #2563eb; }
.safety {
text-align: center;
margin-top: 16px;
font-size: 13px;
color: #475569;
}
</style>
</head>
<body>
<div class="card">
${image ? `<img class="card-image" src="${escapeHtml(image)}" alt="${escapeHtml(title)}" />` : ''}
<div class="card-body">
<div class="card-site">
${favicon ? `<img src="${escapeHtml(favicon)}" alt="" />` : ''}
<span>${escapeHtml(domain)}</span>
</div>
<h1 class="card-title">${escapeHtml(title)}</h1>
<p class="card-desc">${escapeHtml(description)}</p>
<div class="card-meta">
<span>Clicks: ${url.clickCount.toLocaleString()}</span>
<span>Created: ${url.createdAt.toLocaleDateString()}</span>
</div>
<div class="destination">
<div class="destination-label">Destination URL</div>
${escapeHtml(url.originalUrl)}
</div>
<a href="${escapeHtml(url.originalUrl)}" class="btn">
Continue to ${escapeHtml(domain)}
</a>
<p class="safety">
Link này được rút gọn bằng short.ly. Hãy xác minh destination trước khi click.
</p>
</div>
</div>
</body>
</html>`;
}
function extractDomain(url: string): string {
try {
return new URL(url).hostname;
} catch {
return url;
}
}Preview page hiển thị mọi thứ user cần để quyết định có click hay không:
- OG image từ destination (nếu có)
- Title và description scraped từ meta tags
- Full destination URL — để user verify không phải link ác ý
- Click count — social proof
- "Continue to destination" button — hành động rõ ràng
Social Platform Compatibility
Mỗi platform có yêu cầu khác nhau cho link previews. Đây là bảng tổng hợp:
| Platform | Bot User-Agent | Required Tags | Recommended Image Size |
|---|---|---|---|
facebookexternalhit | og:title, og:image, og:description | 1200x630 | |
| Twitter/X | Twitterbot | twitter:card, twitter:title, twitter:image | 1200x628 |
LinkedInBot | og:title, og:image, og:description | 1200x627 | |
| Slack | Slackbot-LinkExpanding | og:title, og:description | 1200x630 |
| Discord | Discordbot | og:title, og:description, og:image | 1200x630 |
WhatsApp | og:title, og:image | 300x200 min | |
| Telegram | TelegramBot | og:title, og:description, og:image | 1200x630 |
Quirks Của Từng Platform
Facebook:
- Cache cực kỳ aggressive — dùng Facebook Sharing Debugger để force refresh
- Yêu cầu images tối thiểu 200x200px
- Ưu tiên aspect ratio 1.91:1 (1200x630)
Twitter/X:
- Fall back sang
og:*tags nếu thiếutwitter:*tags twitter:cardphải làsummaryhoặcsummary_large_image- Images phải nhỏ hơn 5MB
Slack:
- Tự động unfurl links trong channels
- Hiển thị
og:site_namenhư label nhỏ phía trên preview - Respect
og:imagedimensions
LinkedIn:
- Cache rất aggressive — previews có thể mất hàng giờ để update
- Dùng LinkedIn Post Inspector để debug
- Yêu cầu
og:imagephải truy cập được không cần authentication
Vì chúng ta serve cả og:* và twitter:* tags, implementation covers tất cả platforms sẵn rồi.
Background Metadata Refresh
Metadata sẽ stale dần — trang thay đổi title, update image, hoặc thậm chí offline. Chúng ta cần background job để refresh expired metadata:
// src/jobs/refresh-metadata.ts
import { prisma } from '../db';
import { scrapeAndStoreMetadata } from '../services/metadata.service';
import { logger } from '../utils/logger';
const BATCH_SIZE = 50;
const DELAY_BETWEEN_BATCHES_MS = 2000;
export async function refreshExpiredMetadata(): Promise<void> {
const now = new Date();
const expiredMetadata = await prisma.urlMetadata.findMany({
where: {
expiresAt: { lt: now },
url: { isActive: true },
},
include: {
url: { select: { id: true, originalUrl: true } },
},
take: BATCH_SIZE,
orderBy: { expiresAt: 'asc' },
});
if (expiredMetadata.length === 0) {
logger.info('No expired metadata to refresh');
return;
}
logger.info({ count: expiredMetadata.length }, 'Refreshing expired metadata');
let refreshed = 0;
let failed = 0;
for (const record of expiredMetadata) {
try {
await scrapeAndStoreMetadata(record.url.id, record.url.originalUrl);
refreshed++;
} catch (err) {
logger.warn(
{ err, urlId: record.url.id },
'Failed to refresh metadata'
);
failed++;
// Nếu scraping fail, đẩy expiry ra xa để tránh retry liên tục
await prisma.urlMetadata.update({
where: { id: record.id },
data: {
expiresAt: new Date(Date.now() + 6 * 60 * 60 * 1000), // retry sau 6 giờ
},
});
}
}
logger.info({ refreshed, failed }, 'Metadata refresh complete');
}Schedule bằng cron job hoặc setInterval đơn giản:
// src/jobs/scheduler.ts
import { refreshExpiredMetadata } from './refresh-metadata';
import { logger } from '../utils/logger';
const REFRESH_INTERVAL = 60 * 60 * 1000; // Mỗi giờ
export function startScheduler(): void {
logger.info('Starting metadata refresh scheduler');
setInterval(async () => {
try {
await refreshExpiredMetadata();
} catch (err) {
logger.error({ err }, 'Metadata refresh scheduler error');
}
}, REFRESH_INTERVAL);
}Metadata Refresh API Endpoint
Admin users có thể muốn manually trigger metadata refresh cho URL cụ thể:
// src/routes/admin.ts
router.post(
'/api/admin/urls/:id/refresh-metadata',
authenticate,
requireRole('admin'),
async (req: Request, res: Response) => {
const { id } = req.params;
const url = await prisma.url.findUnique({
where: { id },
select: { id: true, originalUrl: true },
});
if (!url) {
return res.status(404).json({ error: 'URL not found' });
}
// Scrape và update metadata synchronously cho admin
const metadata = await safeScrapeMetadata(url.originalUrl);
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24);
const updated = await prisma.urlMetadata.upsert({
where: { urlId: id },
create: {
urlId: id,
ogTitle: metadata.ogTitle,
ogDescription: metadata.ogDescription,
ogImage: metadata.ogImage,
ogSiteName: metadata.ogSiteName,
favicon: metadata.favicon,
expiresAt,
},
update: {
ogTitle: metadata.ogTitle,
ogDescription: metadata.ogDescription,
ogImage: metadata.ogImage,
ogSiteName: metadata.ogSiteName,
favicon: metadata.favicon,
scrapedAt: new Date(),
expiresAt,
},
});
// Invalidate cache
await redis.del(`metadata:${id}`);
return res.json({
message: 'Metadata refreshed',
metadata: updated,
});
}
);Các Vấn Đề Bảo Mật
Link preview functionality giới thiệu nhiều security surfaces. Xử lý từng cái:
1. XSS Qua Scraped Metadata
Destination page kiểm soát OG tags của nó. Trang ác ý có thể set:
<meta property="og:title" content='"><script>alert("xss")</script>' />Cách xử lý: Escape tất cả scraped values trước khi inject vào HTML (xem escapeHtml() ở trên). Không bao giờ dùng innerHTML hoặc template literals mà không escape.
2. SSRF Qua Metadata Scraping
Đã cover phía trên — function validateScrapeTarget() block requests tới internal networks và known metadata endpoints.
3. Resource Exhaustion
Destination page có thể cực kỳ lớn, khiến scraper ngốn hết memory.
Cách xử lý: Limit response body còn 100KB và set 5-second timeout:
// Đã implement trong scraper:
const MAX_BYTES = 100 * 1024; // 100KB limit
const timeout = setTimeout(() => controller.abort(), 5000); // 5s timeout4. Scraper Rate Limiting
Ngăn abuse của scraping functionality:
// src/middleware/scrape-rate-limit.ts
import rateLimit from 'express-rate-limit';
export const scrapeRateLimit = rateLimit({
windowMs: 60 * 1000, // 1 phút
max: 10, // 10 scrape requests mỗi phút mỗi IP
message: { error: 'Too many scrape requests, please try again later' },
keyGenerator: (req) => {
return req.ip || req.headers['x-forwarded-for'] as string || 'unknown';
},
});5. Open Redirect Abuse
Short URLs của chúng ta, theo định nghĩa, là open redirects. Attacker có thể dùng chúng để che giấu malicious URLs sau trusted domain.
Cách xử lý:
- Preview page (
?preview=true) cho phép user verify destination - Bot previews hiển thị actual destination URL trong description
- Admin moderation (từ bài trước) có thể flag suspicious URLs
- Cân nhắc maintain blocklist các known malicious domains
Các Lỗi Thường Gặp
Đây là những sai lầm bạn muốn tránh:
1. Không Escape HTML Trong OG Tag Values
Nếu title của destination page là My Page" /><script>alert(1)</script><meta content=", và bạn inject thẳng vào HTML, đó là lỗ hổng XSS. Luôn escape. Luôn luôn.
2. Scraping Đồng Bộ Khi Tạo URL
// SAI - block API response
const metadata = await scrapeMetadata(originalUrl);
await saveMetadata(urlId, metadata);
return res.json({ shortUrl });
// ĐÚNG - fire and forget
scrapeAndStoreMetadata(urlId, originalUrl).catch(logError);
return res.json({ shortUrl });Scraping mất 1-5 giây. URL creation endpoint nên return trong vòng dưới 100ms.
3. Không Handle Redirects Khi Scraping
Nhiều URLs redirect (HTTP 301/302) trước khi đến trang cuối. Scraper dùng redirect: 'follow' để handle, nhưng cần lưu ý redirect chains có thể dẫn đến timeout hoặc SSRF.
4. Cache Metadata Mãi Mãi
Nếu không bao giờ refresh metadata, previews trở nên stale. Destination page có thể thay đổi title, update image, hoặc offline. 24-hour expiry với stale-while-revalidate xử lý gracefully.
5. Bỏ Qua Non-HTML Responses
Không phải mọi URL đều trỏ tới HTML page. PDFs, images, và API endpoints không có OG tags. Check Content-Type header trước khi parse:
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('text/html')) {
return emptyMetadata(); // Skip non-HTML content
}Testing
Unit Tests: Bot Detection
// src/__tests__/bot-detection.test.ts
import { describe, it, expect } from 'vitest';
import { isSocialBot, identifyBot } from '../utils/bot-detection';
describe('isSocialBot', () => {
it('detects Facebook crawler', () => {
expect(isSocialBot('facebookexternalhit/1.1')).toBe(true);
});
it('detects Twitterbot', () => {
expect(isSocialBot('Twitterbot/1.0')).toBe(true);
});
it('detects Slackbot', () => {
expect(
isSocialBot('Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)')
).toBe(true);
});
it('detects LinkedIn bot', () => {
expect(isSocialBot('LinkedInBot/1.0')).toBe(true);
});
it('detects Discord bot', () => {
expect(
isSocialBot('Mozilla/5.0 (compatible; Discordbot/2.0)')
).toBe(true);
});
it('returns false for normal browsers', () => {
expect(
isSocialBot(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
)
).toBe(false);
});
it('returns false for empty user agent', () => {
expect(isSocialBot('')).toBe(false);
});
it('is case-insensitive', () => {
expect(isSocialBot('FACEBOOKEXTERNALHIT/1.1')).toBe(true);
});
});
describe('identifyBot', () => {
it('returns bot name when detected', () => {
expect(identifyBot('facebookexternalhit/1.1')).toBe('facebookexternalhit');
});
it('returns null for normal browsers', () => {
expect(identifyBot('Mozilla/5.0 (Windows NT 10.0)')).toBeNull();
});
});Unit Tests: Metadata Scraping
// src/__tests__/metadata.service.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { scrapeMetadata } from '../services/metadata.service';
// Mock fetch globally
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe('scrapeMetadata', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('extracts OG tags from HTML', async () => {
mockFetch.mockResolvedValue({
ok: true,
headers: new Headers({ 'content-type': 'text/html' }),
body: createReadableStream(`
<html>
<head>
<meta property="og:title" content="Test Page" />
<meta property="og:description" content="A test page description" />
<meta property="og:image" content="https://example.com/image.png" />
<meta property="og:site_name" content="Example" />
<link rel="icon" href="/favicon.ico" />
</head>
<body></body>
</html>
`),
});
const metadata = await scrapeMetadata('https://example.com/page');
expect(metadata.ogTitle).toBe('Test Page');
expect(metadata.ogDescription).toBe('A test page description');
expect(metadata.ogImage).toBe('https://example.com/image.png');
expect(metadata.ogSiteName).toBe('Example');
expect(metadata.favicon).toBe('/favicon.ico');
});
it('falls back to <title> when og:title is missing', async () => {
mockFetch.mockResolvedValue({
ok: true,
headers: new Headers({ 'content-type': 'text/html' }),
body: createReadableStream(`
<html>
<head><title>Fallback Title</title></head>
<body></body>
</html>
`),
});
const metadata = await scrapeMetadata('https://example.com');
expect(metadata.ogTitle).toBe('Fallback Title');
});
it('returns empty metadata on timeout', async () => {
mockFetch.mockImplementation(() =>
new Promise((_, reject) => {
setTimeout(() => reject(new DOMException('Aborted', 'AbortError')), 100);
})
);
const metadata = await scrapeMetadata('https://slow-site.com');
expect(metadata.ogTitle).toBeNull();
expect(metadata.ogDescription).toBeNull();
});
it('returns empty metadata for non-HTML responses', async () => {
mockFetch.mockResolvedValue({
ok: true,
headers: new Headers({ 'content-type': 'application/json' }),
body: createReadableStream('{"key": "value"}'),
});
const metadata = await scrapeMetadata('https://api.example.com/data');
expect(metadata.ogTitle).toBeNull();
});
});
// Helper tạo ReadableStream từ string
function createReadableStream(text: string): ReadableStream {
return new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
controller.close();
},
});
}Unit Tests: SSRF Prevention
// src/__tests__/ssrf-prevention.test.ts
import { describe, it, expect } from 'vitest';
import { isPrivateIP } from '../utils/ssrf-prevention';
describe('isPrivateIP', () => {
it('blocks loopback addresses', () => {
expect(isPrivateIP('127.0.0.1')).toBe(true);
expect(isPrivateIP('127.0.0.2')).toBe(true);
});
it('blocks Class A private', () => {
expect(isPrivateIP('10.0.0.1')).toBe(true);
expect(isPrivateIP('10.255.255.255')).toBe(true);
});
it('blocks Class B private', () => {
expect(isPrivateIP('172.16.0.1')).toBe(true);
expect(isPrivateIP('172.31.255.255')).toBe(true);
});
it('blocks Class C private', () => {
expect(isPrivateIP('192.168.0.1')).toBe(true);
expect(isPrivateIP('192.168.255.255')).toBe(true);
});
it('blocks link-local', () => {
expect(isPrivateIP('169.254.169.254')).toBe(true);
});
it('allows public IPs', () => {
expect(isPrivateIP('8.8.8.8')).toBe(false);
expect(isPrivateIP('1.1.1.1')).toBe(false);
expect(isPrivateIP('203.0.113.1')).toBe(false);
});
});Integration Test: Redirect Handler
// src/__tests__/redirect.integration.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { app } from '../app';
import { prisma } from '../db';
describe('Redirect with bot detection', () => {
let testUrl: { id: string; shortCode: string };
beforeAll(async () => {
// Tạo test URL với metadata
testUrl = await prisma.url.create({
data: {
shortCode: 'test-bot',
originalUrl: 'https://example.com/article',
isActive: true,
clickCount: 0,
},
});
await prisma.urlMetadata.create({
data: {
urlId: testUrl.id,
ogTitle: 'Test Article',
ogDescription: 'A great test article',
ogImage: 'https://example.com/image.png',
expiresAt: new Date(Date.now() + 86400000),
},
});
});
afterAll(async () => {
await prisma.urlMetadata.deleteMany({ where: { urlId: testUrl.id } });
await prisma.url.delete({ where: { id: testUrl.id } });
});
it('redirects normal browsers with 302', async () => {
const res = await request(app)
.get(`/${testUrl.shortCode}`)
.set('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36');
expect(res.status).toBe(302);
expect(res.headers.location).toBe('https://example.com/article');
});
it('serves OG tags to Facebook bot', async () => {
const res = await request(app)
.get(`/${testUrl.shortCode}`)
.set('User-Agent', 'facebookexternalhit/1.1');
expect(res.status).toBe(200);
expect(res.text).toContain('og:title');
expect(res.text).toContain('Test Article');
expect(res.text).toContain('og:image');
});
it('serves OG tags to Twitter bot', async () => {
const res = await request(app)
.get(`/${testUrl.shortCode}`)
.set('User-Agent', 'Twitterbot/1.0');
expect(res.status).toBe(200);
expect(res.text).toContain('twitter:card');
expect(res.text).toContain('twitter:title');
});
it('serves preview page when requested', async () => {
const res = await request(app)
.get(`/${testUrl.shortCode}?preview=true`)
.set('User-Agent', 'Mozilla/5.0');
expect(res.status).toBe(200);
expect(res.text).toContain('Test Article');
expect(res.text).toContain('Continue to');
expect(res.text).toContain('example.com');
});
it('returns 404 for non-existent short codes', async () => {
const res = await request(app)
.get('/nonexistent')
.set('User-Agent', 'facebookexternalhit/1.1');
expect(res.status).toBe(404);
});
});Tổng Hợp Toàn Bộ
Đây là kiến trúc hoàn chỉnh của link preview system:
Hoàn Thành Series
Chúc mừng bạn. Bạn vừa build xong một production-ready URL shortener từ empty directory đến ứng dụng full-featured, deployed hoàn chỉnh. Nhìn lại hành trình của toàn bộ series:
| Phase | Chúng Ta Đã Build |
|---|---|
| Phase 1 | Express + TypeScript project setup, endpoint POST /api/shorten đầu tiên |
| Phase 2 | PostgreSQL database với Prisma ORM, schema design, migrations |
| Phase 3 | Base62 short code generation, collision handling, custom aliases |
| Phase 4 | Redirect engine, click analytics, geolocation tracking |
| Phase 5 | Redis caching cho sub-millisecond redirects, rate limiting |
| Phase 6 | JWT authentication, user accounts, API keys |
| Phase 7 | React frontend với dashboard, charts, QR codes |
| Phase 8 | Unit, integration, và load testing với Vitest + k6 |
| Phase 9 | Docker, CI/CD, Prometheus monitoring, production deployment |
| Phase 10 | Admin RBAC system với role-based access control |
| Phase 11 | URL moderation, flagging, và content review workflows |
| Phase 12 | Analytics dashboard với advanced metrics và visualizations |
| Phase 13 | React admin dashboard UI với data tables và moderation tools |
| Phase 14 | SEO, OG tag scraping, dynamic OG images, bot-aware redirects |
Đó là một web application hoàn chỉnh, professional-grade, covering API design, database modeling, caching, authentication, frontend development, testing, deployment, admin tooling và SEO.
Tiếp Theo Là Gì?
Đây là một số advanced features bạn có thể thêm để đưa project lên tầm cao hơn:
- Custom domains — cho phép users mang domain riêng (ví dụ
links.mycompany.com) - Link scheduling — schedule links activate/deactivate vào thời điểm cụ thể
- Team workspaces — shared URL management cho organizations
- API documentation — tự động generate Swagger/OpenAPI docs
- A/B testing — redirect tới different destinations dựa trên percentage splits
- Webhook notifications — notify users khi links đạt click milestones
- Link-in-bio pages — aggregate nhiều short links thành landing page
Dù bạn build gì tiếp theo, giờ bạn đã có engineering foundation vững chắc. Các patterns đã học — caching strategies, background processing, bot detection, SSRF prevention, cache-aside với stale-while-revalidate — đây là những patterns tương tự được dùng ở scale bởi các công ty như Bitly, Rebrandly và short.io.
Build something great.
Series: Build a URL Shortener
Previous: Phase 13: React Admin Dashboard UI
📬 Đăng ký nhận bản tin
Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.
Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.
💬 Comments
Đăng nhập để bình luận
Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.