Why Build Your Own URL Shortener
Bit.ly charges $8/month for custom domains and 500 branded links. Rebrandly charges $29/month. For an African startup where every dollar counts, paying ₦12,000/month for URL shortening is wasteful.
With Cloudflare Workers and KV, you build a URL shortener that handles millions of redirects, runs at 300+ edge locations, and costs exactly $0 on the free tier. You own the data, you own the domain, and you never hit someone else's rate limit.

The Free Stack
| Tool | Purpose | Free Limit | Cost After |
|---|---|---|---|
| Cloudflare Workers | Redirect logic | 100k requests/day | $5/month for 10M |
| Cloudflare KV | URL storage | 100k reads/day, 1k writes/day | $0.50/month |
| Cloudflare Pages | Admin dashboard | Unlimited | Free forever |
| Wrangler CLI | Deployment tool | Free | Free |
Total cost: $0/month
Step 1: Set Up the Project
npm install -g wrangler
wrangler login
mkdir url-shortener && cd url-shortener
wrangler init
Update wrangler.toml:
name = "url-shortener"
main = "src/index.js"
compatibility_date = "2026-01-01"
[[kv_namespaces]]
binding = "LINKS"
id = "your-kv-namespace-id"
[vars]
BASE_URL = "https://s.yourdomain.com"
Create the KV namespace:
wrangler kv:namespace create LINKS
Step 2: Build the Core Shortener
// src/index.js
function generateId(length = 6) {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
let result = ''
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}
function isUrlValid(string) {
try {
new URL(string)
return true
} catch {
return false
}
}
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url)
const path = url.pathname
// POST /api/shorten — create a short link
if (path === '/api/shorten' && request.method === 'POST') {
const authHeader = request.headers.get('Authorization')
if (authHeader !== 'Bearer ' + env.ADMIN_TOKEN) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { url: longUrl, customCode } = body
if (!longUrl || !isUrlValid(longUrl)) {
return Response.json({ error: 'Valid URL required' }, { status: 400 })
}
const code = customCode || generateId()
const existing = await env.LINKS.get(code)
if (existing) {
return Response.json({ error: 'Code already in use' }, { status: 409 })
}
const linkData = {
originalUrl: longUrl,
createdAt: new Date().toISOString(),
clicks: 0,
country: request.cf?.country || 'unknown'
}
await env.LINKS.put(code, JSON.stringify(linkData))
return Response.json({
shortUrl: env.BASE_URL + '/' + code,
code,
originalUrl: longUrl
}, { status: 201 })
}
// GET /api/stats/:code — get link stats
if (path.startsWith('/api/stats/') && request.method === 'GET') {
const code = path.split('/').pop()
const data = await env.LINKS.get(code)
if (!data) {
return Response.json({ error: 'Link not found' }, { status: 404 })
}
return Response.json({ code, ...JSON.parse(data) })
}
// GET /:code — redirect to original URL
if (path.length > 1 && request.method === 'GET') {
const code = path.slice(1)
const data = await env.LINKS.get(code)
if (!data) {
return new Response('Link not found', { status: 404 })
}
const link = JSON.parse(data)
link.clicks += 1
ctx.waitUntil(env.LINKS.put(code, JSON.stringify(link)))
return Response.redirect(link.originalUrl, 301)
}
// GET / — admin dashboard or landing page
return Response.json({
service: 'URL Shortener',
endpoints: {
'POST /api/shorten': 'Create a short link',
'GET /:code': 'Redirect to original URL',
'GET /api/stats/:code': 'Get link analytics'
}
})
}
}
Step 3: Add Rate Limiting
Prevent abuse without spending a cent:
// Rate limiting using KV with expiration
async function checkRateLimit(env, ip) {
const key = 'ratelimit:' + ip
const current = parseInt(await env.LINKS.get(key) || '0')
if (current >= 10) {
return false // 10 shortens per hour
}
await env.LINKS.put(key, String(current + 1), { expirationTtl: 3600 })
return true
}
// Add to your POST /api/shorten handler:
const ip = request.headers.get('cf-connecting-ip') || 'unknown'
if (!(await checkRateLimit(env, ip))) {
return Response.json({ error: 'Rate limit exceeded' }, { status: 429 })
}
Step 4: Build a Simple Admin Frontend
Create a single-page admin that you can deploy on Cloudflare Pages:
<!-- public/index.html -->
<!DOCTYPE html>
<html>
<head>
<title>URL Shortener Admin</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-gray-50 p-8">
<div class="max-w-md mx-auto space-y-6">
<h1 class="text-2xl font-bold">URL Shortener</h1>
<div>
<input id="longUrl" placeholder="https://your-long-url.com/page"
class="w-full border rounded-lg px-4 py-2 mb-2" />
<input id="customCode" placeholder="Custom code (optional)"
class="w-full border rounded-lg px-4 py-2 mb-2" />
<button onclick="shorten()" class="w-full bg-black text-white py-2 rounded-lg">
Shorten URL
</button>
</div>
<div id="result" class="hidden bg-green-50 border border-green-200 rounded-lg p-4">
<p class="text-sm text-gray-600">Short URL:</p>
<a id="shortUrl" class="text-blue-600 font-mono text-lg"></a>
</div>
</div>
<script>
async function shorten() {
const url = document.getElementById('longUrl').value
const customCode = document.getElementById('customCode').value
const token = localStorage.getItem('admin_token') || prompt('Enter admin token:')
localStorage.setItem('admin_token', token)
const res = await fetch('/api/shorten', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
},
body: JSON.stringify({ url, customCode })
})
const data = await res.json()
if (data.error) { alert(data.error); return }
document.getElementById('shortUrl').href = data.shortUrl
document.getElementById('shortUrl').textContent = data.shortUrl
document.getElementById('result').classList.remove('hidden')
}
</script>
</body>
</html>
Step 5: Deploy and Add Custom Domain
wrangler deploy
# Your shortener is live at https://url-shortener.your-name.workers.dev
Add a short custom domain:
- Go to Cloudflare Dashboard → Workers → your worker → Triggers
- Click Add Custom Domain
- Enter
s.yourdomain.com(short domains look better in texts and social media)
In Nigeria, a .com.ng domain costs ₦2,500/year. A 2-letter subdomain like s.yourdomain.com.ng is perfect for sharing on WhatsApp and Twitter.
Cost Comparison: Build vs Buy
| Service | Monthly Cost | Custom Domain | Analytics | Branded Links |
|---|---|---|---|---|
| Bit.ly Free | $0 | No | Limited | 50/month |
| Bit.ly Paid | $8 | Yes | Yes | Unlimited |
| Rebrandly | $29 | Yes | Advanced | Unlimited |
| **Your Workers Shortener** | **$0** | **Yes** | **Yes** | **Unlimited** |
At scale (1M+ redirects/month), your Cloudflare Workers cost is $5/month. Bit.ly would cost $8–$29. Your solution is cheaper and you own every byte of data.
Common Mistakes
| Mistake | What Happens | Fix |
|---|---|---|
| Using 302 instead of 301 redirects | SEO penalty, browsers re-fetch every time | Use 301 for permanent redirects |
| No rate limiting on shorten endpoint | Anyone creates thousands of spam links | Rate limit by IP using KV with TTL |
| Storing full URLs without validation | XSS via javascript: URLs | Always validate URLs with the URL constructor |
| No admin authentication | Anyone can create links | Require a Bearer token on write endpoints |
| Not handling KV read failures | Users see errors on redirect | Add fallback and retry logic for KV reads |

