Adding Storage with KV

Lesson 3 · Cloudflare Workers · ~12 minutes

In the last lesson you deployed a Worker that returned dynamic responses — but it had no memory. Every request started from scratch. Now we'll give your Worker persistent storage by building a URL shortener with Workers KV.

What Is KV?

KV (Key-Value) is Cloudflare's globally distributed key-value store. Think of it as a giant dictionary that lives at the edge — you store a key (like "gh") and a value (like "https://github.com"), and any Worker in the world can read it back in milliseconds.

Three things to know about KV:

  1. Eventually consistent. When you write a value, it propagates to all 300+ edge locations within about 60 seconds. Reads from the same location are instant, but other locations may serve stale data briefly.
  2. Optimized for reads. Reads are fast and cheap. Writes are slower and limited. This makes KV perfect for data that's written rarely but read constantly.
  3. Simple API. Two main operations: get(key) and put(key, value). No queries, no indexes, no schema.
Free Tier Limits

KV gives you 100,000 reads/day, 1,000 writes/day, and 1 GB of storage on the free tier. A URL shortener is the textbook use case: you write a link once and read it thousands of times.

Project Setup

Create a new Worker project for the URL shortener:

npm create cloudflare@latest -- url-shortener
cd url-shortener

Choose "Hello World" Worker when prompted. Now create a KV namespace — this is the actual storage bucket your Worker will use:

npx wrangler kv namespace create LINKS

Wrangler will output something like this:

🌀 Creating namespace with title "url-shortener-LINKS"
✨ Success!
Add the following to your wrangler.jsonc:
"kv_namespaces": [{ "binding": "LINKS", "id": "abc123def456" }]

Copy that configuration into your wrangler.jsonc:

{
  "name": "url-shortener",
  "main": "src/index.js",
  "compatibility_date": "2024-01-01",
  "kv_namespaces": [
    {
      "binding": "LINKS",
      "id": "your-namespace-id-here"
    }
  ]
}
What's a Binding?

The "binding": "LINKS" tells Cloudflare to make this KV namespace available to your Worker as env.LINKS. The binding name is how your code references the namespace — the id is the actual namespace identifier on Cloudflare's side.

Building the URL Shortener

Our shortener will handle three routes:

Step 1: The HTML Form

First, let's create the form that users will see at the root path:

function getHomePage(host) {
  return `<!DOCTYPE html>
<html>
<head>
  <title>URL Shortener</title>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 500px; margin: 4rem auto; padding: 0 1rem; }
    input, button { padding: 0.5rem; font-size: 1rem; }
    input[type="url"] { width: 100%; margin-bottom: 0.5rem; }
    #result { margin-top: 1rem; padding: 1rem; background: #f0f9ff; border-radius: 4px; display: none; }
  </style>
</head>
<body>
  <h1>🔗 URL Shortener</h1>
  <form id="form">
    <input type="url" id="url" placeholder="https://example.com/very/long/url" required>
    <input type="text" id="slug" placeholder="Custom slug (optional)">
    <button type="submit">Shorten</button>
  </form>
  <div id="result"></div>
  <script>
    document.getElementById('form').addEventListener('submit', async (e) => {
      e.preventDefault();
      const resp = await fetch('/api/shorten', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          url: document.getElementById('url').value,
          slug: document.getElementById('slug').value || undefined
        })
      });
      const data = await resp.json();
      const result = document.getElementById('result');
      if (resp.ok) {
        result.innerHTML = 'Short URL: <a href="' + data.shortUrl + '">' + data.shortUrl + '</a>';
      } else {
        result.textContent = 'Error: ' + data.error;
      }
      result.style.display = 'block';
    });
  </script>
</body>
</html>`;
}

Step 2: The Shorten Endpoint

This handles POST requests — it takes a URL, generates or uses a slug, stores it in KV, and returns the short URL:

async function handleShorten(request, env) {
  const { url, slug } = await request.json();

  // Validate the URL
  try {
    new URL(url);
  } catch {
    return Response.json({ error: "Invalid URL" }, { status: 400 });
  }

  // Use provided slug or generate a random one
  const key = slug || crypto.randomUUID().slice(0, 6);

  // Check if slug already exists
  const existing = await env.LINKS.get(key);
  if (existing) {
    return Response.json({ error: "Slug already taken" }, { status: 409 });
  }

  // Store in KV
  await env.LINKS.put(key, url);

  const host = new URL(request.url).origin;
  return Response.json({ shortUrl: `${host}/${key}`, slug: key });
}
Slug Generation

crypto.randomUUID().slice(0, 6) gives you a 6-character random string like "a1b2c3". That's 36^6 ≈ 2 billion possible slugs — more than enough for a personal URL shortener.

Step 3: The Redirect Handler

This looks up a slug in KV and redirects the user:

async function handleRedirect(slug, env) {
  const url = await env.LINKS.get(slug);

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

  return Response.redirect(url, 302);
}

Step 4: The Router

Wire everything together with a simple router based on the request path and method:

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

    // Home page
    if (path === "/" && request.method === "GET") {
      return new Response(getHomePage(url.origin), {
        headers: { "Content-Type": "text/html" },
      });
    }

    // Shorten API
    if (path === "/api/shorten" && request.method === "POST") {
      return handleShorten(request, env);
    }

    // Redirect: any other GET path is treated as a slug
    if (request.method === "GET") {
      const slug = path.slice(1); // Remove leading "/"
      return handleRedirect(slug, env);
    }

    return new Response("Method not allowed", { status: 405 });
  },
};

The Complete Worker

Here's the entire src/index.js in one file:

function getHomePage(origin) {
  return `<!DOCTYPE html>
<html>
<head>
  <title>URL Shortener</title>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 500px; margin: 4rem auto; padding: 0 1rem; }
    input, button { padding: 0.5rem; font-size: 1rem; }
    input[type="url"] { width: 100%; margin-bottom: 0.5rem; }
    #result { margin-top: 1rem; padding: 1rem; background: #f0f9ff; border-radius: 4px; display: none; }
  </style>
</head>
<body>
  <h1>🔗 URL Shortener</h1>
  <form id="form">
    <input type="url" id="url" placeholder="https://example.com/very/long/url" required>
    <input type="text" id="slug" placeholder="Custom slug (optional)">
    <button type="submit">Shorten</button>
  </form>
  <div id="result"></div>
  <script>
    document.getElementById('form').addEventListener('submit', async (e) => {
      e.preventDefault();
      const resp = await fetch('/api/shorten', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          url: document.getElementById('url').value,
          slug: document.getElementById('slug').value || undefined
        })
      });
      const data = await resp.json();
      const result = document.getElementById('result');
      if (resp.ok) {
        result.innerHTML = 'Short URL: <a href="' + data.shortUrl + '">' + data.shortUrl + '</a>';
      } else {
        result.textContent = 'Error: ' + data.error;
      }
      result.style.display = 'block';
    });
  </script>
</body>
</html>`;
}

async function handleShorten(request, env) {
  const { url, slug } = await request.json();

  // Validate the URL
  try {
    new URL(url);
  } catch {
    return Response.json({ error: "Invalid URL" }, { status: 400 });
  }

  // Use provided slug or generate a random one
  const key = slug || crypto.randomUUID().slice(0, 6);

  // Check if slug already exists
  const existing = await env.LINKS.get(key);
  if (existing) {
    return Response.json({ error: "Slug already taken" }, { status: 409 });
  }

  // Store in KV
  await env.LINKS.put(key, url);

  const host = new URL(request.url).origin;
  return Response.json({ shortUrl: `${host}/${key}`, slug: key });
}

async function handleRedirect(slug, env) {
  const url = await env.LINKS.get(slug);

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

  return Response.redirect(url, 302);
}

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

    // Home page
    if (path === "/" && request.method === "GET") {
      return new Response(getHomePage(url.origin), {
        headers: { "Content-Type": "text/html" },
      });
    }

    // Shorten API
    if (path === "/api/shorten" && request.method === "POST") {
      return handleShorten(request, env);
    }

    // Redirect: any other GET path is treated as a slug
    if (request.method === "GET") {
      const slug = path.slice(1); // Remove leading "/"
      return handleRedirect(slug, env);
    }

    return new Response("Method not allowed", { status: 405 });
  },
};

Test Locally

Start the local dev server:

npx wrangler dev

Wrangler simulates KV locally, so everything works without deploying. Test the API with curl:

# Create a short link
curl -X POST http://localhost:8787/api/shorten \
  -H "Content-Type: application/json" \
  -d '{"url": "https://developers.cloudflare.com/kv/get-started/", "slug": "kv"}'

# Response: {"shortUrl":"http://localhost:8787/kv","slug":"kv"}

# Test the redirect
curl -I http://localhost:8787/kv

# Response: HTTP/1.1 302 Found
#           Location: https://developers.cloudflare.com/kv/get-started/

Or open http://localhost:8787 in your browser and use the HTML form.

Local KV Persistence

Wrangler stores local KV data in a .wrangler/ directory in your project. Data persists between restarts of wrangler dev — so your test links won't disappear when you stop the server.

Deploy It

Push to Cloudflare's edge network:

npx wrangler deploy

Your URL shortener is now live at https://url-shortener.<your-subdomain>.workers.dev. Test it the same way you tested locally — create a link and verify the redirect works:

# Create a link on your live Worker
curl -X POST https://url-shortener.YOUR-SUBDOMAIN.workers.dev/api/shorten \
  -H "Content-Type: application/json" \
  -d '{"url": "https://github.com", "slug": "gh"}'

# Visit https://url-shortener.YOUR-SUBDOMAIN.workers.dev/gh → redirects to GitHub
Eventual Consistency in Practice

After you create a short link, it's immediately readable from the same edge location. But if someone in another continent hits your shortener within ~60 seconds of creation, they might get a 404. For a personal URL shortener this is fine — links are created once and used repeatedly.

What You Learned

Quiz

KV has 1,000 writes per day on the free tier. Why is a URL shortener a good fit for KV?

📖 Primary Source

Workers KV Getting Started — official documentation covering the full KV API, limits, and configuration.

💬 Questions? Ask me anything that's unclear. For example: "When should I use KV vs D1?" or "How do I delete a stored link?" — I'm your teacher, use me.
← Back Next →