The Problem: Twitter’s 280-Character Limit

When sharing technical blog posts on Twitter, I constantly hit the 280-character limit. Long URLs consume precious space that should be used for actual content. For example:

F h A u t v l t a l p i s l U : a R / b L / l i e w a i m f t d o h e r v U b c T o o M x n : . t c e 1 o n 5 m t 5 / : p c o O h s n a t l r s y a / c b 1 t u 2 e i 5 r l s d c i h n a g r - a c c o t m e p r l s e t e - o i d c - l o g i n - f l o w - u r l s / ? u t m _ s o u r c e = t w i t t e r & u t m _ m e d i u m = s o c i a l & u t m _ c a m p a i g n = b l o g _ p o s t

This leaves barely enough room for a meaningful tweet. Third-party URL shorteners like Bitly work, but they:

  • Cost money for custom domains ($29/month for Bitly Pro)
  • Don’t give you full control over your data
  • May inject their own analytics or tracking
  • Could shut down and break all your links

The Solution: Cloudflare Workers

Cloudflare Workers is a serverless platform that runs your code at the edge, across Cloudflare’s global network. Combined with KV (Key-Value) storage, it’s perfect for building a URL shortener.

Why Cloudflare Workers?

  • Free Tier: 100,000 requests/day
  • Global: <15ms response time worldwide
  • Custom Domain: Use your own domain
  • No Servers: Fully managed, auto-scaling
  • Simple: JavaScript/TypeScript code

Architecture Overview

The system has three main components:

U s 3 h e 0 t r 1 t C ( K K V } p c l E V e a R s l o d y l u c e : i u g S : u r a d / c d e t e l m i / k f o a : : p r e s l N r b a e x : a e a c { " i c a r t g 1 . g t m e e w e 2 . n p x o 3 . : + l a W r " e m o k " U . p r ) . T c l k . M o e e . m . r " p / c a p o r o m a s / m t s e s / t / a e a b r r c s t 1 i 2 c 3 l e / ? u t m _ s o u r c e = t w i t t e r & u t m _ m e d i u m = s h o r t l i n k

Implementation

Step 1: Worker Code

Create worker.js with the main logic:

export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    // Handle short URL redirects
    if (url.pathname.startsWith('/s/')) {
      return handleRedirect(url, env);
    }

    // Handle API: create short URL
    if (url.pathname === '/api/shorten') {
      return handleCreate(request, env);
    }

    // Handle API: get statistics
    if (url.pathname.startsWith('/api/stats/')) {
      return handleStats(url, env);
    }

    return new Response('Not Found', { status: 404 });
  }
};

async function handleRedirect(url, env) {
  const code = url.pathname.split('/s/')[1];

  // Get mapping from KV
  const mapping = await env.URL_MAPPINGS.get(code, { type: 'json' });

  if (!mapping) {
    return new Response('Short URL not found', { status: 404 });
  }

  // Build target URL with UTM parameters
  const targetUrl = new URL(mapping.url);
  const source = url.searchParams.get('s') || 'short';

  targetUrl.searchParams.set('utm_source', source);
  targetUrl.searchParams.set('utm_medium', 'shortlink');
  targetUrl.searchParams.set('utm_campaign', mapping.campaign || 'general');

  // Track stats asynchronously
  trackAccess(code, source, env);

  // 301 permanent redirect
  return Response.redirect(targetUrl.toString(), 301);
}

async function handleCreate(request, env) {
  // Verify API key
  const authHeader = request.headers.get('Authorization');
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return new Response('Unauthorized', { status: 401 });
  }

  const apiKey = authHeader.substring(7);
  if (apiKey !== env.API_KEY) {
    return new Response('Invalid API key', { status: 401 });
  }

  const body = await request.json();
  const { url: longUrl, code, campaign } = body;

  // Generate or use provided short code
  const shortCode = code || generateCode();

  // Store in KV
  await env.URL_MAPPINGS.put(shortCode, JSON.stringify({
    url: longUrl,
    campaign: campaign || 'general',
    created: new Date().toISOString()
  }));

  return Response.json({
    shortUrl: `https://example.com/s/${shortCode}`,
    code: shortCode,
    longUrl
  });
}

function generateCode() {
  const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
  let code = '';
  for (let i = 0; i < 6; i++) {
    code += chars[Math.floor(Math.random() * chars.length)];
  }
  return code;
}

async function trackAccess(code, source, env) {
  const statsKey = `stats:${code}`;
  const stats = await env.URL_MAPPINGS.get(statsKey, { type: 'json' }) || {
    total: 0,
    sources: {}
  };

  stats.total++;
  stats.sources[source] = (stats.sources[source] || 0) + 1;
  stats.lastAccess = new Date().toISOString();

  await env.URL_MAPPINGS.put(statsKey, JSON.stringify(stats));
}

Step 2: Configuration

Create wrangler.toml:

name = "url-shortener"
main = "worker.js"
compatibility_date = "2024-01-01"

[[kv_namespaces]]
binding = "URL_MAPPINGS"
id = "YOUR_KV_NAMESPACE_ID"

[[routes]]
pattern = "yourdomain.com/s/*"
zone_name = "yourdomain.com"

[[routes]]
pattern = "yourdomain.com/api/shorten"
zone_name = "yourdomain.com"

[[routes]]
pattern = "yourdomain.com/api/stats/*"
zone_name = "yourdomain.com"

Step 3: Deploy

# Install Wrangler CLI
npm install -g wrangler

# Login to Cloudflare
wrangler login

# Create KV namespace
wrangler kv:namespace create URL_MAPPINGS

# Set API key (for authentication)
wrangler secret put API_KEY

# Deploy
wrangler deploy

Python Client Integration

Create a Python client for easy integration:

import os
import requests
from typing import Optional

class URLShortener:
    def __init__(self, api_key: Optional[str] = None):
        self.api_key = api_key or os.getenv('SHORTENER_API_KEY')
        self.api_endpoint = 'https://yourdomain.com/api/shorten'

    def create(self, long_url: str, code: Optional[str] = None,
               campaign: str = 'blog_post') -> Optional[str]:
        if not self.api_key:
            return None

        try:
            response = requests.post(
                self.api_endpoint,
                json={
                    'url': long_url,
                    'code': code,
                    'campaign': campaign
                },
                headers={'Authorization': f'Bearer {self.api_key}'},
                timeout=10
            )

            if response.status_code == 200:
                return response.json()['shortUrl']
        except Exception as e:
            print(f'Error creating short URL: {e}')

        return None

def shorten_url_for_twitter(long_url: str) -> str:
    """Create short URL with automatic fallback"""
    shortener = URLShortener()
    short_url = shortener.create(long_url)

    # Fallback to simplified UTM if shortening fails
    return short_url if short_url else f'{long_url}?utm_source=twitter'

Usage Example

from url_shortener import shorten_url_for_twitter

# Original URL (115 characters)
url = 'https://example.com/posts/building-self-hosted-url-shortener-cloudflare-workers/'

# Shortened URL (32 characters)
short = shorten_url_for_twitter(url)
# Result: https://example.com/s/abc123

# Character savings: 83 characters (72% reduction)
print(f'Saved {len(url) - len(short)} characters!')

Results

Before and after comparison for Twitter:

URL Type Length Available for Content
Full URL + UTM 155 chars 125 chars (44%)
Short URL 32 chars 248 chars (88%)

98% increase in available space for content!

GitHub Actions Integration

Automate short URL creation in your CI/CD pipeline:

name: Auto Publish

on:
  push:
    branches: [main]

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Setup URL Shortener
        run: |
          echo "SHORTENER_API_KEY=${{ secrets.SHORTENER_API_KEY }}" >> $GITHUB_ENV

      - name: Publish to Twitter
        run: |
          python3 publish.py --use-short-urls

Cost Analysis

Cloudflare Workers Free Tier:

  • 100,000 requests/day
  • 1GB KV storage
  • Unlimited bandwidth

Estimated usage for a blog:

  • ~500 short URL clicks/day
  • ~5 new URLs created/day
  • ~1MB KV storage used

Cost: $0/month (well within free tier)

Comparison:

  • Bitly Pro: $29/month
  • TinyURL Pro: $9.99/month
  • Self-hosted: $0/month ✨

Performance

Tested from different locations:

S S L T S a h o o i n a n k n n d y g F g o o a r h n p a a o n i r c e i s c o < < < < < 1 1 1 8 1 0 5 2 m 0 m m m s m s s s s

Thanks to Cloudflare’s edge network, responses are blazing fast worldwide.

Security Considerations

  1. API Authentication: Creating short URLs requires API key
  2. Public Access: Redirects work without authentication
  3. Anonymous Stats: No IP addresses or personal data stored
  4. Rate Limiting: Built-in via Cloudflare

Monitoring

View real-time metrics in Cloudflare Dashboard:

  • Request count
  • Error rate
  • P50/P95/P99 latency
  • KV read/write operations

Advanced Features

Custom Short Codes

Create memorable short URLs:

shortener.create(
    long_url='https://example.com/posts/oauth-guide/',
    code='oauth'  # Custom code
)
# Result: https://example.com/s/oauth

Click Analytics

Track where your clicks come from:

curl https://example.com/api/stats/abc123

Response:

{
  "total": 142,
  "sources": {
    "twitter": 98,
    "linkedin": 32,
    "direct": 12
  },
  "lastAccess": "2025-11-27T10:30:00Z"
}

Conclusion

Building a self-hosted URL shortener with Cloudflare Workers provides:

Complete control over your URLs ✅ Zero cost (free tier is generous) ✅ Global performance (<15ms worldwide) ✅ Custom domain (your brand) ✅ Privacy (no third-party tracking) ✅ Reliability (99.99% uptime SLA)

For Twitter and other character-limited platforms, this solution provides 98% more space for content compared to full URLs.

The complete implementation takes under 5 minutes to deploy and is production-ready. Perfect for developers, bloggers, and anyone sharing links on social media.

Resources

Happy link shortening! 🚀