BetterLink Logo BetterLink Blog
Switch Language
Toggle Theme

Building Your Own URL Shortener with Workers + KV Storage: A Complete Guide

Cloudflare kv

Why I Built My Own URL Shortener

Here’s what happened: I’d been using a third-party URL shortener for nearly two years. Then one morning, I woke up to find all my short links dead—the service provider had shut down without warning. Hundreds of links I’d shared on social media were now 404s.

That’s when I decided: I need a URL shortener that I fully control. No more worrying about third-party services disappearing overnight.

I discovered that Cloudflare Workers + KV storage is perfect for this:

  • Generous free tier (100,000 requests/day)
  • Global edge network with 200+ data centers
  • Simple deployment with just a few lines of code
  • Complete data ownership

In this guide, I’ll show you how I built my own short link service with custom slugs and visit tracking. The code is all here—you can have it running in about 30 minutes.

Why Choose Workers + KV?

What is Cloudflare Workers?

Think of Workers as serverless functions running on Cloudflare’s edge network. Write your code once, and it automatically deploys to 200+ global locations. Users are routed to the nearest node for ultra-low latency.

The free tier is incredibly generous:

  • 100,000 requests per day
  • 10ms CPU time per request
  • Perfect for personal use or small teams

KV Storage Advantages

KV (Key-Value) storage is Cloudflare’s distributed database optimized for edge computing:

  • Blazing fast reads: Median 12ms latency thanks to edge caching
  • Global sync: Data propagates to all nodes within 60 seconds
  • Free tier: 100,000 reads and 1,000 writes per day

For URL shorteners, KV is a perfect match:

  • Short codes as keys, original URLs as values
  • Read-heavy workload (few creates, many redirects)
  • Global distribution for fast access anywhere

Comparison with Third-Party Services

FeatureThird-PartySelf-Hosted Workers + KV
Data ControlOn their serversFully yours
CustomizationFixed featuresUnlimited customization
ReliabilityMay shut downCloudflare-backed
AdsPossible interstitialsAd-free
CostMay charge feesBasically free
SpeedDepends on providerGlobal edge network

Building Your URL Shortener from Scratch

Enough theory—let’s get hands-on.

Prerequisites

1. Sign up for Cloudflare

Head to cloudflare.com and create a free account.

2. Install Wrangler CLI

Wrangler is Cloudflare’s official command-line tool for managing Workers projects.

npm install -g wrangler
# or with yarn
yarn global add wrangler

After installation, log in to your Cloudflare account:

wrangler login

This opens a browser window for authorization—just click allow.

3. Create a Project

mkdir my-shortlink
cd my-shortlink
wrangler init

Follow the prompts and choose to create a JavaScript project (TypeScript works too).

Step 1: Create KV Namespace

KV storage requires creating a “namespace”—think of it as a database table.

Run these commands:

# Create production KV namespace
wrangler kv namespace create SHORTLINKS

# Create preview namespace for local testing
wrangler kv namespace create SHORTLINKS --preview

The commands return two IDs like this:

{ binding = "SHORTLINKS", id = "abc123..." }
{ binding = "SHORTLINKS", preview_id = "def456..." }

Important: Save these IDs—you’ll need them next.

Then edit wrangler.toml to add the KV binding:

name = "my-shortlink"
main = "src/index.js"
compatibility_date = "2025-12-01"

# KV namespace binding
kv_namespaces = [
  { binding = "SHORTLINKS", id = "your production ID", preview_id = "your preview ID" }
]

The binding = "SHORTLINKS" means you can access this KV store via env.SHORTLINKS in your code.

Step 2: Implement Core Functionality

Now let’s write the main code. Open src/index.js and add:

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const path = url.pathname.slice(1); // Remove leading /

    // Handle root path
    if (path === '') {
      return new Response('Welcome to URL Shortener!', { status: 200 });
    }

    // GET request: Redirect short link
    if (request.method === 'GET') {
      // Query KV for the original URL
      const targetUrl = await env.SHORTLINKS.get(path);

      if (targetUrl) {
        // Found it, 301 redirect
        return Response.redirect(targetUrl, 301);
      } else {
        // Not found, return 404
        return new Response('Short link not found', { status: 404 });
      }
    }

    // POST request: Create short link
    if (request.method === 'POST') {
      try {
        const body = await request.json();
        const { url: targetUrl, code } = body;

        // Basic validation
        if (!targetUrl) {
          return new Response('Missing url parameter', { status: 400 });
        }

        // Generate short code
        const shortCode = code || generateRandomCode();

        // Check if code already exists
        const existing = await env.SHORTLINKS.get(shortCode);
        if (existing) {
          return new Response('Short code already exists', { status: 409 });
        }

        // Store in KV
        await env.SHORTLINKS.put(shortCode, targetUrl);

        // Return result
        return new Response(JSON.stringify({
          shortCode,
          shortUrl: `${url.origin}/${shortCode}`,
          targetUrl
        }), {
          status: 201,
          headers: { 'Content-Type': 'application/json' }
        });

      } catch (error) {
        return new Response('Invalid request format', { status: 400 });
      }
    }

    // Other methods not supported
    return new Response('Method not allowed', { status: 405 });
  }
};

// Generate random short code (6-character alphanumeric)
function generateRandomCode(length = 6) {
  const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
  let code = '';
  for (let i = 0; i < length; i++) {
    code += chars.charAt(Math.floor(Math.random() * chars.length));
  }
  return code;
}

Code Explanation:

  1. GET request: User visits yourdomain.com/abc123, we query KV for abc123, then 301 redirect to the original URL
  2. POST request: Accept url and optional code parameters, generate random code if not provided, store in KV
  3. generateRandomCode: Generates 6-character random alphanumeric string

Step 3: Local Testing

Test locally before deploying:

wrangler dev

This starts a local server at http://localhost:8787.

Test creating a short link:

curl -X POST http://localhost:8787 \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com"}'

Response:

{
  "shortCode": "aBc123",
  "shortUrl": "http://localhost:8787/aBc123",
  "targetUrl": "https://example.com"
}

Test accessing the short link:

Open http://localhost:8787/aBc123 in your browser—it should redirect to https://example.com.

If everything works, basic functionality is done!

Step 4: Add Custom Slug Support

The code already supports custom slugs—just include a code parameter in your POST request:

curl -X POST http://localhost:8787 \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com", "code": "my-link"}'

For better reliability, add validation:

// Add before generating short code in POST handler

// Validate custom slug format
if (code) {
  // Only allow letters, numbers, hyphens
  if (!/^[a-zA-Z0-9-]+$/.test(code)) {
    return new Response('Invalid code format (alphanumeric and hyphens only)', { status: 400 });
  }
  // Length limit
  if (code.length < 3 || code.length > 20) {
    return new Response('Code length must be 3-20 characters', { status: 400 });
  }
}

This prevents users from creating invalid slugs with special characters or excessive length.

Step 5: Implement Visit Tracking

Often you want to know how many times each link is accessed.

Approach:

  • Increment visit count on each redirect
  • Store stats in KV with key format stats:{shortCode}

Modify the GET request handler:

// GET request: Redirect short link
if (request.method === 'GET') {
  const targetUrl = await env.SHORTLINKS.get(path);

  if (targetUrl) {
    // Asynchronously update stats (non-blocking)
    const statsKey = `stats:${path}`;

    // Update stats in background without blocking redirect
    env.SHORTLINKS.get(statsKey).then(count => {
      const newCount = (parseInt(count) || 0) + 1;
      env.SHORTLINKS.put(statsKey, newCount.toString());
    });

    return Response.redirect(targetUrl, 301);
  } else {
    return new Response('Short link not found', { status: 404 });
  }
}

Add stats query endpoint:

// Add before GET request handler
if (path.startsWith('stats/')) {
  const shortCode = path.slice(6); // Remove stats/ prefix
  const statsKey = `stats:${shortCode}`;
  const count = await env.SHORTLINKS.get(statsKey);

  return new Response(JSON.stringify({
    shortCode,
    visits: parseInt(count) || 0
  }), {
    headers: { 'Content-Type': 'application/json' }
  });
}

Now you can query visit counts at http://localhost:8787/stats/abc123.

Note: KV doesn’t support atomic operations, so high-concurrency stats may be inaccurate. For precise tracking, use Durable Objects. But for most personal use cases, this approach is sufficient.

Step 6: Deploy to Production

Once testing passes, deploy to Cloudflare’s global network:

wrangler deploy

After deployment, Wrangler gives you a URL like https://my-shortlink.your-subdomain.workers.dev.

This URL is your short link service, accessible globally with blazing speed.

Bind custom domain (optional):

If you have your own domain (e.g., short.example.com), bind it in the Cloudflare Dashboard:

  1. Go to Workers & Pages
  2. Select your Worker
  3. Click Settings > Triggers
  4. Add Custom Domain

After binding, access short links via your domain: https://short.example.com/abc123.

Advanced Features

Basic functionality is complete, but you can add more:

Sometimes you need to create multiple links at once:

// Add batch creation logic in POST handler
if (request.method === 'POST' && url.pathname === '/batch') {
  try {
    const body = await request.json();
    const links = body.links; // Format: [{url, code?}, ...]

    if (!Array.isArray(links)) {
      return new Response('links must be an array', { status: 400 });
    }

    const results = [];
    for (const link of links) {
      const { url: targetUrl, code } = link;
      const shortCode = code || generateRandomCode();

      // Check if exists
      const existing = await env.SHORTLINKS.get(shortCode);
      if (!existing) {
        await env.SHORTLINKS.put(shortCode, targetUrl);
        results.push({ shortCode, targetUrl, success: true });
      } else {
        results.push({ shortCode, targetUrl, success: false, error: 'Code exists' });
      }
    }

    return new Response(JSON.stringify({ results }), {
      headers: { 'Content-Type': 'application/json' }
    });

  } catch (error) {
    return new Response('Invalid request format', { status: 400 });
  }
}

Usage:

curl -X POST http://localhost:8787/batch \
  -H "Content-Type: application/json" \
  -d '{
    "links": [
      {"url": "https://example1.com", "code": "link1"},
      {"url": "https://example2.com"}
    ]
  }'

2. Set Expiration Time

KV supports TTL (Time To Live) for auto-expiring links:

// Add expirationTtl parameter when storing
await env.SHORTLINKS.put(shortCode, targetUrl, {
  expirationTtl: 86400 // Auto-delete after 24 hours (seconds)
});

For user-defined expiration:

const { url: targetUrl, code, ttl } = body;

const options = {};
if (ttl) {
  options.expirationTtl = parseInt(ttl);
}

await env.SHORTLINKS.put(shortCode, targetUrl, options);

3. Access Control

Add API token authentication to restrict link creation:

// Add to wrangler.toml
# [vars]
# API_TOKEN = "your-secret-token"

// Add validation before POST handler
if (request.method === 'POST') {
  const token = request.headers.get('Authorization');
  if (token !== `Bearer ${env.API_TOKEN}`) {
    return new Response('Unauthorized', { status: 401 });
  }
  // ... continue with link creation
}

Usage with token:

curl -X POST http://localhost:8787 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your-secret-token" \
  -d '{"url": "https://example.com"}'

4. Rate Limiting

Prevent abuse with simple rate limiting:

// Use IP address for rate limiting
const clientIp = request.headers.get('CF-Connecting-IP');
const rateLimitKey = `ratelimit:${clientIp}`;

// Get current count
const count = await env.SHORTLINKS.get(rateLimitKey);
if (parseInt(count) >= 10) {
  return new Response('Too many requests, try again later', { status: 429 });
}

// Increment count, expires in 1 hour
const newCount = (parseInt(count) || 0) + 1;
await env.SHORTLINKS.put(rateLimitKey, newCount.toString(), {
  expirationTtl: 3600 // 1 hour
});

This limits each IP to 10 link creations per hour.

Performance Optimization & Best Practices

Performance Tips

1. Caching Strategy

KV reads are already fast (12ms median), but you can add in-memory caching:

// Simple in-memory cache using Map
const cache = new Map();

const targetUrl = cache.get(path) || await env.SHORTLINKS.get(path);
if (targetUrl) {
  cache.set(path, targetUrl);
  return Response.redirect(targetUrl, 301);
}

Note: Worker memory isn’t persistent—it’s lost on restart.

2. Reduce KV Writes

Free tier allows 1,000 writes/day. Frequent stat writes can exceed this.

Solutions:

  • Use Durable Objects for stats (supports atomic operations)
  • Write to KV every N visits instead
  • Use Cloudflare Analytics Engine

3. CORS Configuration

If your frontend needs to call the API, add CORS headers:

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type',
};

// OPTIONS request handler
if (request.method === 'OPTIONS') {
  return new Response(null, { headers: corsHeaders });
}

// Add CORS headers to responses
return new Response(body, {
  headers: { ...headers, ...corsHeaders }
});

Cost Control

Cloudflare Workers free tier is generous, but be aware:

Free Tier:

  • 100,000 requests/day
  • KV: 100,000 reads/day, 1,000 writes/day
  • 10ms CPU time per request

Paid Plan Costs (Workers Paid, starts at $5/month):

  • $0.50 per million requests
  • KV reads: $0.50 per million
  • KV writes: $5.00 per million
  • KV storage: $0.50 per GB/month

For personal use, you’ll rarely exceed free tier. Even for small teams with tens of thousands of daily visits, it’s sufficient.

Tips to save requests:

  • Use 301 redirects (browser cached) not 302
  • Host static resources (like admin panel) on Workers Pages
  • Set appropriate TTLs to auto-clean expired links

Security Considerations

1. Prevent Malicious Links

If your service is public, it could be abused for phishing links.

Recommendations:

  • Require API token authentication
  • Use blocklist to filter known malicious domains
  • Log creator IPs for tracking

2. Prevent Code Collisions

While 6-character alphanumeric has 62^6 ≈ 56.8 billion possibilities, always check:

// Check if code exists before creation
const existing = await env.SHORTLINKS.get(shortCode);
if (existing) {
  return new Response('Short code already exists', { status: 409 });
}

3. Restrict Target URLs

Add a whitelist to only allow specific domains:

const allowedDomains = ['example.com', 'mywebsite.com'];
const targetDomain = new URL(targetUrl).hostname;

if (!allowedDomains.some(d => targetDomain.endsWith(d))) {
  return new Response('Domain not allowed', { status: 403 });
}

My Experience Using It

After several months of use, here’s my take:

Pros:

  • Truly fast: Global latency under 50ms, much faster than my old third-party service
  • Stable: Cloudflare’s network is rock-solid, zero downtime
  • Worry-free: Deploy and forget, auto-scales, no traffic concerns
  • Free: My daily requests stay well within free tier

Minor gotchas:

  • KV write latency: KV is eventually consistent—writes take up to 60 seconds to sync globally. Not an issue for short links since they’re not accessed immediately after creation
  • Stats imprecision: KV lacks atomic operations, so high-concurrency stats may drift. For precise tracking, use Durable Objects (but costs more beyond free tier)

Future plans:

  • Build a simple dashboard with Workers Pages for visual link management
  • Integrate Cloudflare Analytics for detailed visitor data (sources, locations)
  • Add QR code generation for offline sharing

Conclusion

Building a URL shortener with Cloudflare Workers + KV is fast and cost-effective. Less than 100 lines of core code, one-command deployment, and complete data ownership—no more third-party shutdown worries.

If you have similar needs, I highly recommend trying it. All code is provided—follow along and you can be live in 30 minutes.

Quick Recap:

  1. Sign up for Cloudflare, install Wrangler
  2. Create KV namespace, configure wrangler.toml
  3. Write code to handle GET (redirect) and POST (create link) requests
  4. Test locally, deploy to production

You can gradually add features like stats, batch creation, and expiration. The code is yours—customize however you want. That feeling of complete control is pretty great.

Feel free to leave comments with questions. Happy building!

Published on: Dec 1, 2025 · Modified on: Dec 4, 2025

Related Posts