How to Build a URL Shortener (2026)
A URL shortener is a great learning project that covers databases, redirects, analytics, and API design. Here's how to build one.
Architecture
User visits: short.io/abc123
→ Server looks up "abc123" in database
→ Finds: https://example.com/very/long/url
→ Returns 301 redirect
→ Logs click analytics
Database Schema (Drizzle)
export const links = pgTable('links', {
id: uuid('id').primaryKey().defaultRandom(),
shortCode: text('short_code').notNull().unique(),
url: text('url').notNull(),
createdAt: timestamp('created_at').defaultNow(),
clicks: integer('clicks').default(0),
userId: text('user_id'), // Optional: link to user
})
export const clicks = pgTable('clicks', {
id: uuid('id').primaryKey().defaultRandom(),
linkId: uuid('link_id').references(() => links.id),
timestamp: timestamp('timestamp').defaultNow(),
referrer: text('referrer'),
userAgent: text('user_agent'),
country: text('country'),
ip: text('ip'),
})
Create Short Link API
// app/api/links/route.ts
import { nanoid } from 'nanoid'
export async function POST(req: Request) {
const { url } = await req.json()
// Validate URL
try { new URL(url) } catch {
return Response.json({ error: 'Invalid URL' }, { status: 400 })
}
const shortCode = nanoid(7) // e.g., "abc1234"
await db.insert(links).values({ shortCode, url })
return Response.json({
shortUrl: `${process.env.BASE_URL}/${shortCode}`,
shortCode,
})
}
Redirect Handler
// app/[code]/route.ts
export async function GET(req: Request, { params }: { params: { code: string } }) {
const link = await db.query.links.findFirst({
where: eq(links.shortCode, params.code),
})
if (!link) {
return new Response('Not found', { status: 404 })
}
// Log click (async, don't block redirect)
logClick(link.id, req).catch(console.error)
// Increment counter
db.update(links)
.set({ clicks: sql`clicks + 1` })
.where(eq(links.id, link.id))
.execute()
// 301 for permanent, 302 for temporary
return Response.redirect(link.url, 301)
}
async function logClick(linkId: string, req: Request) {
await db.insert(clicks).values({
linkId,
referrer: req.headers.get('referer'),
userAgent: req.headers.get('user-agent'),
country: req.headers.get('cf-ipcountry') || null,
})
}
Frontend (React)
'use client'
import { useState } from 'react'
function URLShortener() {
const [url, setUrl] = useState('')
const [shortUrl, setShortUrl] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const res = await fetch('/api/links', {
method: 'POST',
body: JSON.stringify({ url }),
})
const data = await res.json()
setShortUrl(data.shortUrl)
}
return (
<form onSubmit={handleSubmit}>
<input value={url} onChange={e => setUrl(e.target.value)} placeholder="Paste URL..." />
<button type="submit">Shorten</button>
{shortUrl && (
<div>
<a href={shortUrl}>{shortUrl}</a>
<button onClick={() => navigator.clipboard.writeText(shortUrl)}>Copy</button>
</div>
)}
</form>
)
}
Analytics Dashboard
// app/api/links/[code]/stats/route.ts
export async function GET(req: Request, { params }) {
const link = await db.query.links.findFirst({
where: eq(links.shortCode, params.code),
})
const clickData = await db
.select({
date: sql`DATE(timestamp)`,
count: sql`COUNT(*)`,
})
.from(clicks)
.where(eq(clicks.linkId, link.id))
.groupBy(sql`DATE(timestamp)`)
.orderBy(sql`DATE(timestamp)`)
const topReferrers = await db
.select({
referrer: clicks.referrer,
count: sql`COUNT(*)`,
})
.from(clicks)
.where(eq(clicks.linkId, link.id))
.groupBy(clicks.referrer)
.orderBy(desc(sql`COUNT(*)`))
.limit(10)
return Response.json({ totalClicks: link.clicks, clickData, topReferrers })
}
Production Considerations
- Rate limiting — prevent abuse (100 links/hour per IP)
- URL validation — block malicious URLs
- Custom short codes — let users choose their own
- Expiration — optional TTL for links
- QR codes — generate QR code for each short link
FAQ
Should I build or use Dub.co?
Build for learning. Use Dub.co ($24/mo) for production — it includes analytics, custom domains, and team features.
What about collisions?
nanoid(7) gives 4.4 trillion possibilities. Collision probability is negligible. Add a unique constraint for safety.
How fast should redirects be?
<50ms. Use Redis cache for hot links. Database lookup for cold links.
Bottom Line
A URL shortener is a great project covering databases, redirects, analytics, and API design. Build it with Next.js + Drizzle + PostgreSQL. Add Redis caching for performance. For production use, consider Dub.co — it handles the edge cases you don't want to maintain.