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

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
| Feature | Third-Party | Self-Hosted Workers + KV |
|---|---|---|
| Data Control | On their servers | Fully yours |
| Customization | Fixed features | Unlimited customization |
| Reliability | May shut down | Cloudflare-backed |
| Ads | Possible interstitials | Ad-free |
| Cost | May charge fees | Basically free |
| Speed | Depends on provider | Global 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 wranglerAfter installation, log in to your Cloudflare account:
wrangler loginThis opens a browser window for authorization—just click allow.
3. Create a Project
mkdir my-shortlink
cd my-shortlink
wrangler initFollow 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 --previewThe 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:
GETrequest: User visitsyourdomain.com/abc123, we query KV forabc123, then 301 redirect to the original URLPOSTrequest: Accepturland optionalcodeparameters, generate random code if not provided, store in KVgenerateRandomCode: Generates 6-character random alphanumeric string
Step 3: Local Testing
Test locally before deploying:
wrangler devThis 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 deployAfter 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:
- Go to Workers & Pages
- Select your Worker
- Click Settings > Triggers
- 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:
1. Batch Link Creation
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:
- Sign up for Cloudflare, install Wrangler
- Create KV namespace, configure wrangler.toml
- Write code to handle GET (redirect) and POST (create link) requests
- 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

Complete Guide to Deploying Astro on Cloudflare: SSR Configuration + 3x Speed Boost for China

Building an Astro Blog from Scratch: Complete Guide from Homepage to Deployment in 1 Hour
