Build a URL Shortener: Cloudflare Workers + KV
Build13 min read·March 4, 2026·--

Build a URL Shortener: Cloudflare Workers + KV

Create your own bit.ly with Cloudflare Workers and KV — custom domains, click analytics, and rate limiting all running at the edge. Zero cost on the free tier, scales to millions of redirects.

@
@kivorablog
March 4, 2026
Share

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


ToolPurposeFree LimitCost After
Cloudflare WorkersRedirect logic100k requests/day$5/month for 10M
Cloudflare KVURL storage100k reads/day, 1k writes/day$0.50/month
Cloudflare PagesAdmin dashboardUnlimitedFree forever
Wrangler CLIDeployment toolFreeFree

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

ServiceMonthly CostCustom DomainAnalyticsBranded Links
Bit.ly Free$0NoLimited50/month
Bit.ly Paid$8YesYesUnlimited
Rebrandly$29YesAdvancedUnlimited
**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

MistakeWhat HappensFix
Using 302 instead of 301 redirectsSEO penalty, browsers re-fetch every timeUse 301 for permanent redirects
No rate limiting on shorten endpointAnyone creates thousands of spam linksRate limit by IP using KV with TTL
Storing full URLs without validationXSS via javascript: URLsAlways validate URLs with the URL constructor
No admin authenticationAnyone can create linksRequire a Bearer token on write endpoints
Not handling KV read failuresUsers see errors on redirectAdd fallback and retry logic for KV reads
Read more on Kivora Blog

Read more on Kivora Blog

Get started →