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.
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:
get(key) and put(key, value). No queries, no indexes, no schema.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.
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"
}
]
}
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.
Our shortener will handle three routes:
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>`;
}
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 });
}
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.
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);
}
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 });
},
};
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 });
},
};
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.
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.
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
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.
wrangler.jsonc, access via env.BINDING_NAME.get(key) and put(key, value) cover most use cases.KV has 1,000 writes per day on the free tier. Why is a URL shortener a good fit for KV?
Workers KV Getting Started — official documentation covering the full KV API, limits, and configuration.