Building a URL Shortener with Cloudflare Workers and KV


I built a URL shortener last weekend. Not because the world needs another one — it absolutely doesn’t — but because it’s an excellent project for learning Cloudflare Workers and KV storage. The whole thing runs on Cloudflare’s free tier, handles millions of requests without breaking a sweat, and took me about two hours to build.

Here’s the complete walkthrough.

Why Cloudflare Workers?

If you haven’t worked with Cloudflare Workers before, they’re essentially serverless functions that run at the edge — meaning your code executes on Cloudflare’s global network of data centres, close to wherever the user is located. Response times are typically under 20ms.

Workers pair naturally with KV (Key-Value) storage, which is Cloudflare’s distributed key-value store. For a URL shortener, the data model is perfect: the short code is the key, the destination URL is the value. You don’t need a relational database, you don’t need to worry about scaling, and reads from KV are extremely fast.

The Cloudflare Workers documentation is actually decent — better than most cloud provider docs I’ve worked with.

Prerequisites

You’ll need:

  • A Cloudflare account (free tier works)
  • Node.js installed locally
  • Wrangler CLI (npm install -g wrangler)
  • A custom domain pointed at Cloudflare (optional but recommended)

Log in to Wrangler:

wrangler login

Step 1: Set Up the Project

wrangler init url-shortener
cd url-shortener

Choose “Hello World” as the template when prompted. This gives you a minimal Worker with a single fetch handler.

Step 2: Create the KV Namespace

KV namespaces are Cloudflare’s way of organising key-value data. You need one for your URL mappings.

wrangler kv namespace create "URLS"
wrangler kv namespace create "URLS" --preview

The first command creates the production namespace, the second creates a preview namespace for local development. Wrangler will output binding IDs — add these to your wrangler.toml:

[[kv_namespaces]]
binding = "URLS"
id = "your-production-id-here"
preview_id = "your-preview-id-here"

Step 3: The Worker Code

Replace the contents of src/index.js (or src/index.ts if you chose TypeScript):

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

    // POST /create — Create a new short URL
    if (request.method === "POST" && path === "create") {
      return handleCreate(request, env);
    }

    // GET /:code — Redirect to destination
    if (request.method === "GET" && path.length > 0 && path !== "favicon.ico") {
      return handleRedirect(path, env);
    }

    // Root path — simple landing page
    return new Response("URL Shortener is running.", {
      headers: { "Content-Type": "text/plain" },
    });
  },
};

async function handleCreate(request, env) {
  try {
    const body = await request.json();
    const { url, code } = body;

    if (!url) {
      return jsonResponse({ error: "URL is required" }, 400);
    }

    // Validate the URL
    try {
      new URL(url);
    } catch {
      return jsonResponse({ error: "Invalid URL format" }, 400);
    }

    // Generate code if not provided
    const shortCode = code || generateCode(6);

    // Check if code already exists
    const existing = await env.URLS.get(shortCode);
    if (existing) {
      return jsonResponse({ error: "Code already taken" }, 409);
    }

    // Store the mapping
    await env.URLS.put(shortCode, url);

    return jsonResponse({
      shortCode,
      shortUrl: `https://your-domain.com/${shortCode}`,
      destination: url,
    }, 201);
  } catch (err) {
    return jsonResponse({ error: "Invalid request body" }, 400);
  }
}

async function handleRedirect(code, env) {
  const destination = await env.URLS.get(code);

  if (!destination) {
    return new Response("Not found", { status: 404 });
  }

  return Response.redirect(destination, 301);
}

function generateCode(length) {
  const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
  let result = "";
  for (let i = 0; i < length; i++) {
    result += chars.charAt(Math.floor(Math.random() * chars.length));
  }
  return result;
}

function jsonResponse(data, status = 200) {
  return new Response(JSON.stringify(data), {
    status,
    headers: { "Content-Type": "application/json" },
  });
}

Notice I excluded characters that look similar (0/O, 1/l/I) from the code generator. Small detail, but it prevents confusion when people manually type short URLs.

Step 4: Add Authentication

You don’t want random people creating short URLs on your service. Add a simple API key check:

async function handleCreate(request, env) {
  const authHeader = request.headers.get("Authorization");
  if (authHeader !== `Bearer ${env.API_KEY}`) {
    return jsonResponse({ error: "Unauthorized" }, 401);
  }
  // ... rest of the function
}

Set the API key as a secret:

wrangler secret put API_KEY

Wrangler will prompt you to enter the key value. This gets stored securely and is accessible as env.API_KEY in your Worker.

Step 5: Deploy

wrangler deploy

That’s it. Your URL shortener is live on Cloudflare’s global network.

Step 6: Test It

Create a short URL:

curl -X POST https://your-worker.your-subdomain.workers.dev/create \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your-api-key" \
  -d '{"url": "https://example.com/very/long/path", "code": "test"}'

Test the redirect:

curl -I https://your-worker.your-subdomain.workers.dev/test

You should get a 301 redirect to your destination URL.

What I’d Add Next

This basic version works, but here’s what I’d build on top of it:

Click analytics. Before redirecting, increment a counter in KV. Something like await env.URLS.put(stats:${code}, (parseInt(await env.URLS.get(stats:${code})) || 0) + 1). This adds a few milliseconds but gives you basic usage data.

Expiring URLs. KV supports TTL (time-to-live) on keys. Add an optional expiresIn parameter to your create endpoint and pass it as expirationTtl when writing to KV.

A simple frontend. Build a basic HTML form that calls the create endpoint. Serve it from the Worker itself on the root path.

Rate limiting. Cloudflare offers rate limiting as a separate product, or you can implement basic throttling using KV to track request counts per IP.

Cost

On Cloudflare’s free plan, you get 100,000 Worker requests per day and 1,000 KV reads per day. That’s enough for a personal URL shortener. If you’re building something larger, the paid plan starts at $5/month with 10 million requests included.

The entire project is about 100 lines of code, runs globally with no server management, and costs nothing to operate at personal scale. Hard to argue with that.

Murtaza Khan is a self-taught developer who writes tutorials based on things he actually builds.