Blueprint: a URL shortener (your first full-stack app)
Paste a long link, get a short one back, and click it to jump — the perfect first project.
What are we building?
A URL shortener is the classic 'hello world' of full-stack apps. You paste a long, ugly link, the app stores it under a short random code, and visiting that code redirects you to the original link. With Cloudflare you can build the whole thing with one HTML page, one Worker, and one KV namespace — no server to manage.
There are exactly two things the app does: (1) create — turn a long URL into a short code and save it; (2) resolve — take a short code and send the browser to the long URL. Let's see both as a picture.
Think of it like…
A coat-check ticket. You hand over a bulky coat (the long URL) and get a tiny numbered ticket (the short code). Later you show the ticket and the attendant brings back exactly your coat. KV is the rack of coats; the code is your ticket number.
How the pieces fit
Three small layers, each with one job. Requests flow front-end → Worker → KV and back. Because the Worker also serves the static form, your whole app lives at a single URL.
1. Front-end form
A plain HTML page with one input and a fetch() call. No framework needed.
2. Worker (the brain)
One function that reads the request, decides create vs resolve, and talks to KV.
3. KV (the memory)
A global key-value store holding every code → URL pair, read back in milliseconds.
The short code
A tiny random string like abc123 that becomes the key and the last part of the short URL.
What happens on each click
Both flows talk to the same three players: your Browser, the Worker, and KV. Here is the exact back-and-forth for creating a link and then opening it.
Build it step by step
Five steps from empty folder to a live link shortener. Create a project with npm create cloudflare@latest, then follow along — every file you need is below.
Create a KV namespace
A namespace is one isolated bucket of key-value pairs. Wrangler prints an id — copy it for the next step.
npx wrangler kv namespace create LINKSBind KV in wrangler.jsonc
This config file names your entry Worker, points at the static files, and makes the namespace available in code as env.LINKS.
{ "name": "url-shortener", "main": "src/index.js", "compatibility_date": "2025-06-01", // Serve everything in ./public as static files (the HTML form lives here) "assets": { "directory": "./public" }, // Make the KV namespace available in code as env.LINKS "kv_namespaces": [ { "binding": "LINKS", "id": "<paste-your-id-here>" } ] }Build the front-end form
Save this as public/index.html. It is served automatically at / and posts the URL to your Worker with fetch().
<!DOCTYPE html> <html lang="zh-Hant"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>URL Shortener 短網址</title> </head> <body> <h1>Shorten a URL 縮短網址</h1> <form id="shorten-form"> <input id="long-url" type="url" placeholder="https://example.com/a/very/long/path" required /> <button type="submit">Shorten</button> </form> <p id="result"></p> <script> const form = document.getElementById("shorten-form"); form.addEventListener("submit", async (event) => { event.preventDefault(); const longUrl = document.getElementById("long-url").value; // POST the long URL to our Worker as JSON const response = await fetch("/api/shorten", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: longUrl }), }); const data = await response.json(); const result = document.getElementById("result"); if (data.short) { const shortUrl = location.origin + data.short; result.innerHTML = 'Short link: <a href="' + shortUrl + '">' + shortUrl + "</a>"; } else { result.textContent = data.error || "Something went wrong"; } }); </script> </body> </html>Write the Worker
Save this as src/index.js. On POST it generates a code and saves it; on GET it looks the code up and redirects. This is the entire back-end.
// src/index.js — the entire back-end of the URL shortener // Build a random short code, e.g. "a8Kp2Z", from safe characters. function makeCode(length = 6) { const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; const bytes = crypto.getRandomValues(new Uint8Array(length)); let code = ""; for (const byte of bytes) { code += chars[byte % chars.length]; } return code; } export default { async fetch(request, env) { const url = new URL(request.url); // 1) CREATE a short link: POST /api/shorten with { "url": "https://..." } if (request.method === "POST" && url.pathname === "/api/shorten") { const body = await request.json(); const longUrl = body.url; // Validate the input before trusting it. const ok = longUrl && (longUrl.startsWith("http://") || longUrl.startsWith("https://")); if (!ok) { return Response.json( { error: "Please send a valid http(s) URL" }, { status: 400 } ); } const code = makeCode(6); await env.LINKS.put(code, longUrl); // save the pair: code -> long URL return Response.json({ short: "/" + code }); } // 2) RESOLVE a short link: GET /<code> -> 302 redirect to the long URL if (request.method === "GET") { const code = url.pathname.slice(1); // drop the leading "/" const longUrl = await env.LINKS.get(code); // look the code up in KV if (longUrl) { return Response.redirect(longUrl, 302); } return new Response("Short link not found", { status: 404 }); } return new Response("Method not allowed", { status: 405 }); }, };Run locally, then deploy
wrangler dev runs it on your machine; wrangler deploy puts it on Cloudflare's global network with a public URL.
# Run it on your own machine first npx wrangler dev # open http://localhost:8787 , paste a URL, then click the short link # Happy with it? Ship it worldwide: npx wrangler deploy
Key concepts
KV stores code → URL
Each short code is a key; the long URL is its value. put(code, url) saves the pair, get(code) reads it back. No tables, no SQL.
302 redirect
Response.redirect(url, 302) tells the browser 'this link lives somewhere else, go there now'. 302 = a temporary redirect, so you can change the target later.
Random short code
We pick 6 characters at random from a-z, A-Z, 0-9 — over 56 billion combinations, so collisions are extremely unlikely for a small app.
Eventual consistency is fine here
A freshly created code can take up to ~60s to appear in every region. For sharing links that is perfectly acceptable.
Static assets + Worker
The HTML form is a static file served automatically; any path that is not a file (like /abc123) falls through to your Worker.
Validate the input
Always check the submitted URL really starts with http:// or https:// before saving it — never trust raw form data.
301 vs 302
301 = permanent (browsers and search engines cache it hard); 302 = temporary. Start with 302 while learning so you can freely change where a code points.
Level it up: count the clicks
Once the basics work, a fun next step is counting how many times each short link is opened. The quick way: keep a counter in KV. The robust way: log each click into a D1 (SQL) table so you can run analytics.
Quick way — a KV counter
// Inside the GET branch, right after you read longUrl:
const key = "clicks:" + code;
const hits = parseInt((await env.LINKS.get(key)) || "0", 10);
await env.LINKS.put(key, String(hits + 1)); // best-effort click countRobust way — a D1 table
With D1 you keep one row per link and one row per click. The shape looks like this:
-- schema.sql (run with: npx wrangler d1 execute mydb --file schema.sql)
CREATE TABLE links (
code TEXT PRIMARY KEY,
long_url TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE clicks (
id INTEGER PRIMARY KEY,
code TEXT NOT NULL,
clicked_at INTEGER NOT NULL
);KV or D1?
KV is perfect for the code → URL lookup (tons of fast reads). D1 is better for click logs you want to query and aggregate. Many real apps use both together.
Tips, traps & limits
Open redirects can be abused
Because anyone can shorten any URL, bad actors could hide phishing links behind your domain. For a learning project it's fine; for production, consider a blocklist or only allowing your own URLs.
- KV free tier: 100,000 reads/day and 1,000 writes/day — plenty for a personal shortener.
- Reads are fast and cheap; writes are limited to ~1 per second per key, which is fine since each code is written once.
- Check for an existing code before saving if you want to be extra safe against the tiny collision chance.
- A new code may take up to ~60 seconds to be readable in every region (eventual consistency).
Related products
menu_bookOfficial docsopen_in_new