Linkly - Link Shortener
In this tutorial, you’ll build a complete link shortening service with:
- D1 Database for persistent link storage
- KV Cache for fast redirects
- Rate Limiting middleware to prevent abuse
- Analytics tracking (click counts)
Project Overview
Section titled “Project Overview”The final project structure:
Directoryapp/
- layout.tsx
- page.tsx
- globals.css
Directory[code]/
- route.ts # GET /:code - redirect handler
Directorystats/
Directory[code]/
- page.tsx # Link statistics page
Directoryapi/
- middleware.ts # Rate limiting
Directoryshorten/
- route.ts # POST /api/shorten
Directorylib/
- db.ts # D1 database helpers
- cache.ts # KV cache helpers
Directorycomponents/
- shorten-form.tsx # Client component for URL input
Directorymigrations/
- 0001_create_links.sql
- cloudwerk.config.ts
- wrangler.toml
- package.json
Step 1: Create the Project
Section titled “Step 1: Create the Project”-
Create a new Cloudwerk app:
Terminal window pnpm dlx @cloudwerk/create-app linkly --renderer hono-jsxcd linkly -
Install dependencies:
Terminal window pnpm install
Step 2: Set Up D1 Database
Section titled “Step 2: Set Up D1 Database”-
Add a D1 database binding:
Terminal window npm run bindings add d1When prompted:
- Binding name:
DB - Database name:
linkly-db - Add to wrangler.toml?: Yes
- Binding name:
-
Generate TypeScript types for your bindings:
Terminal window npm run bindings generate-typesThis creates type definitions so you can import
DBfrom@cloudwerk/core/bindings. -
Create a migration file:
Terminal window wrangler d1 migrations create linkly-db create_linksThis creates
migrations/0001_create_links.sql.
Database Schema
Section titled “Database Schema”Open the migration file and add the links table:
CREATE TABLE links ( id TEXT PRIMARY KEY, url TEXT NOT NULL, code TEXT UNIQUE NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP, clicks INTEGER DEFAULT 0);
CREATE INDEX idx_links_code ON links(code);Apply the migration locally:
wrangler d1 migrations apply linkly-db --localStep 3: Set Up KV Cache
Section titled “Step 3: Set Up KV Cache”-
Add a KV namespace for caching:
Terminal window npm run bindings add kvWhen prompted:
- Binding name:
LINKLY_CACHE - Namespace name:
linkly-cache - Add to wrangler.toml?: Yes
- Binding name:
-
Regenerate TypeScript types:
Terminal window npm run bindings generate-typesThis adds
LINKLY_CACHEto your bindings types.
The KV cache will store URLs for fast redirect lookups, avoiding database queries for popular links.
Step 4: Create Database Helpers
Section titled “Step 4: Create Database Helpers”Create a module to interact with the D1 database:
import { DB } from '@cloudwerk/core/bindings'
export interface Link { id: string url: string code: string created_at: string clicks: number}
/** * Generate a random 6-character alphanumeric code */export function generateCode(): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' let code = '' for (let i = 0; i < 6; i++) { code += chars.charAt(Math.floor(Math.random() * chars.length)) } return code}
/** * Create a new shortened link */export async function createLink(url: string, code: string): Promise<Link> { const id = crypto.randomUUID()
await DB.prepare('INSERT INTO links (id, url, code) VALUES (?, ?, ?)') .bind(id, url, code) .run()
return { id, url, code, created_at: new Date().toISOString(), clicks: 0, }}
/** * Get a link by its short code */export async function getLinkByCode(code: string): Promise<Link | null> { const result = await DB.prepare('SELECT * FROM links WHERE code = ?') .bind(code) .first<Link>() return result}
/** * Increment the click count for a link */export async function incrementClicks(code: string): Promise<void> { await DB.prepare('UPDATE links SET clicks = clicks + 1 WHERE code = ?') .bind(code) .run()}
/** * Check if a code already exists */export async function codeExists(code: string): Promise<boolean> { const link = await getLinkByCode(code) return link !== null}
/** * Generate a unique code that doesn't exist in the database */export async function generateUniqueCode(): Promise<string> { let code = generateCode() let attempts = 0 const maxAttempts = 10
while (await codeExists(code) && attempts < maxAttempts) { code = generateCode() attempts++ }
if (attempts >= maxAttempts) { throw new Error('Failed to generate unique code') }
return code}Step 5: Create Cache Helpers
Section titled “Step 5: Create Cache Helpers”Create a module for KV cache operations:
import { LINKLY_CACHE } from '@cloudwerk/core/bindings'
/** Cache TTL in seconds (1 hour) */const CACHE_TTL = 3600
/** * Get a cached URL by its short code */export async function getCachedUrl(code: string): Promise<string | null> { return LINKLY_CACHE.get(`url:${code}`)}
/** * Cache a URL by its short code */export async function cacheUrl(code: string, url: string): Promise<void> { await LINKLY_CACHE.put(`url:${code}`, url, { expirationTtl: CACHE_TTL, })}Step 6: Create Rate Limiting Middleware
Section titled “Step 6: Create Rate Limiting Middleware”Create middleware to prevent API abuse:
import type { Middleware } from '@cloudwerk/core'import { LINKLY_CACHE } from '@cloudwerk/core/bindings'import { createRateLimiter, createFixedWindowStorage,} from '@cloudwerk/core/middleware'
// Rate limit: 10 requests per minute per IPconst RATE_LIMIT = 10const RATE_WINDOW = 60 // seconds
export const middleware: Middleware = async (request, next) => { // Create rate limiter with KV storage const storage = createFixedWindowStorage(LINKLY_CACHE, 'ratelimit:api:') const rateLimiter = createRateLimiter({ limit: RATE_LIMIT, window: RATE_WINDOW, storage, })
// Check rate limit const { response, result } = await rateLimiter.check(request)
// If rate limited, return 429 response if (response) { return response }
// Continue to route handler const res = await next()
// Add rate limit headers to response const headers = rateLimiter.headers(result) for (const [key, value] of Object.entries(headers)) { res.headers.set(key, String(value)) }
return res}Step 7: Create the Shorten API Route
Section titled “Step 7: Create the Shorten API Route”Create the endpoint to shorten URLs:
import type { CloudwerkHandler } from '@cloudwerk/core'import { json, badRequest } from '@cloudwerk/core'import { createLink, generateUniqueCode } from '../../lib/db'import { cacheUrl } from '../../lib/cache'
interface ShortenRequest { url: string}
interface ShortenResponse { code: string shortUrl: string url: string}
/** * Validate that a string is a valid URL */function isValidUrl(str: string): boolean { try { const url = new URL(str) return url.protocol === 'http:' || url.protocol === 'https:' } catch { return false }}
export const POST: CloudwerkHandler = async (request, _context) => { // Parse request body let body: ShortenRequest try { body = await request.json() } catch { return badRequest('Invalid JSON body') }
// Validate URL const { url } = body if (!url) { return badRequest('URL is required') }
if (!isValidUrl(url)) { return badRequest('Invalid URL format. Must be a valid HTTP or HTTPS URL.') }
// Generate unique short code const code = await generateUniqueCode()
// Store in database await createLink(url, code)
// Pre-cache in KV for fast redirects await cacheUrl(code, url)
// Build response const origin = new URL(request.url).origin const response: ShortenResponse = { code, shortUrl: `${origin}/${code}`, url, }
return json(response, 201)}Step 8: Create the Redirect Route
Section titled “Step 8: Create the Redirect Route”Create the redirect handler for short links:
import type { CloudwerkHandler, CloudwerkHandlerContext } from '@cloudwerk/core'import { redirect, notFoundResponse, getContext } from '@cloudwerk/core'import { getLinkByCode, incrementClicks } from '../lib/db'import { getCachedUrl, cacheUrl } from '../lib/cache'
interface Params { code: string}
export const GET: CloudwerkHandler<Params> = async ( _request, { params }: CloudwerkHandlerContext<Params>) => { const { code } = params const { executionCtx } = getContext()
// Try cache first (fast path) let url = await getCachedUrl(code)
if (url) { // Track click in background executionCtx.waitUntil(incrementClicks(code)) return redirect(url, 302) }
// Cache miss - check database const link = await getLinkByCode(code)
if (!link) { return notFoundResponse('Link not found') }
url = link.url
// Cache for next time and track click in background executionCtx.waitUntil( Promise.all([ cacheUrl(code, url), incrementClicks(code), ]) )
return redirect(url, 302)}Step 9: Update the Home Page
Section titled “Step 9: Update the Home Page”Replace the starter page with the link shortener UI:
import ShortenForm from './components/shorten-form'
export default function HomePage() { return ( <main class="flex flex-col items-center justify-center min-h-screen p-8"> {/* Logo/Brand */} <div class="mb-8"> <h1 class="text-5xl font-bold bg-gradient-to-r from-blue-500 to-cyan-500 bg-clip-text text-transparent"> Linkly </h1> </div>
{/* Tagline */} <p class="text-xl text-gray-600 dark:text-gray-400 mb-8 text-center max-w-md"> Shorten your links, track your clicks </p>
{/* Shorten Form */} <div class="w-full max-w-lg"> <ShortenForm /> </div>
{/* Info */} <div class="mt-12 text-sm text-gray-500 dark:text-gray-500 text-center max-w-md"> <p>Built with Cloudwerk using D1 for storage and KV for caching.</p> <p class="mt-2">Rate limited to 10 requests per minute.</p> </div> </main> )}Step 10: Create the Shorten Form Component
Section titled “Step 10: Create the Shorten Form Component”Create a client component for the URL input form:
'use client'
import { useState } from 'hono/jsx'
interface ShortenResult { code: string shortUrl: string url: string}
export default function ShortenForm() { const [url, setUrl] = useState('') const [result, setResult] = useState<ShortenResult | null>(null) const [error, setError] = useState<string | null>(null) const [loading, setLoading] = useState(false) const [copied, setCopied] = useState(false)
async function handleSubmit(e: Event) { e.preventDefault() setError(null) setResult(null) setLoading(true)
try { const response = await fetch('/api/shorten', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }), })
const data = await response.json()
if (!response.ok) { setError(data.error || 'Failed to shorten URL') return }
setResult(data) setUrl('') } catch { setError('Network error. Please try again.') } finally { setLoading(false) } }
async function copyToClipboard() { if (!result) return await navigator.clipboard.writeText(result.shortUrl) setCopied(true) setTimeout(() => setCopied(false), 2000) }
return ( <div class="space-y-6"> {/* Form */} <form onSubmit={handleSubmit} class="flex gap-3"> <input type="url" value={url} onInput={(e) => setUrl((e.target as HTMLInputElement).value)} placeholder="https://example.com/long-url" required class="flex-1 px-4 py-3 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all" /> <button type="submit" disabled={loading} class="px-6 py-3 bg-blue-500 hover:bg-blue-600 disabled:bg-blue-300 text-white font-medium rounded-lg transition-colors" > {loading ? 'Shortening...' : 'Shorten'} </button> </form>
{/* Error */} {error && ( <div class="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-400"> {error} </div> )}
{/* Result */} {result && ( <div class="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg"> <p class="text-sm text-gray-600 dark:text-gray-400 mb-2">Your shortened link:</p> <div class="flex items-center gap-3"> <a href={result.shortUrl} target="_blank" rel="noopener noreferrer" class="text-lg font-medium text-blue-600 dark:text-blue-400 hover:underline break-all" > {result.shortUrl} </a> <button onClick={copyToClipboard} class="shrink-0 px-3 py-1 text-sm bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded transition-colors" > {copied ? 'Copied!' : 'Copy'} </button> </div> <p class="text-xs text-gray-500 dark:text-gray-500 mt-2 truncate"> Original: {result.url} </p> </div> )} </div> )}Step 11: Update the Layout
Section titled “Step 11: Update the Layout”Update the layout with Linkly branding:
import type { LayoutProps } from '@cloudwerk/core'import globals from './globals.css?url'
export default function RootLayout({ children }: LayoutProps) { return ( <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Linkly - Link Shortener</title> <link rel="stylesheet" href={globals} /> </head> <body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 min-h-screen"> {children} </body> </html> )}Step 12: Create the Stats Page
Section titled “Step 12: Create the Stats Page”Create a page to display link statistics:
import type { PageProps, LoaderArgs } from '@cloudwerk/core'import { NotFoundError } from '@cloudwerk/core'import { getLinkByCode, type Link } from '../../lib/db'
interface StatsLoaderData { link: Link shortUrl: string}
export async function loader({ params, request }: LoaderArgs<{ code: string }>): Promise<StatsLoaderData> { const link = await getLinkByCode(params.code) if (!link) throw new NotFoundError()
const origin = new URL(request.url).origin return { link, shortUrl: `${origin}/${link.code}`, }}
function formatDate(dateString: string): string { const date = new Date(dateString) return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })}
export default function StatsPage({ link, shortUrl }: PageProps & StatsLoaderData) { return ( <main class="flex flex-col items-center justify-center min-h-screen p-8"> {/* Header */} <div class="mb-8"> <a href="/" class="text-4xl font-bold bg-gradient-to-r from-blue-500 to-cyan-500 bg-clip-text text-transparent hover:opacity-80 transition-opacity"> Linkly </a> </div>
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-300 mb-8"> Link Statistics </h2>
{/* Stats Card */} <div class="w-full max-w-lg bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 space-y-6"> {/* Click Count */} <div class="text-center py-4 bg-gradient-to-r from-blue-50 to-cyan-50 dark:from-blue-900/20 dark:to-cyan-900/20 rounded-lg"> <div class="text-5xl font-bold text-blue-600 dark:text-blue-400"> {link.clicks.toLocaleString()} </div> <div class="text-sm text-gray-600 dark:text-gray-400 mt-1"> {link.clicks === 1 ? 'click' : 'clicks'} </div> </div>
{/* Short URL with copy button */} <div> <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1"> Short URL </label> <div class="flex items-center gap-2"> <a href={shortUrl} target="_blank" rel="noopener noreferrer" class="flex-1 text-blue-600 dark:text-blue-400 hover:underline break-all" > {shortUrl} </a> <button onclick={`navigator.clipboard.writeText('${shortUrl}');this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',2000)`} class="shrink-0 px-3 py-1 text-sm bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded transition-colors" > Copy </button> </div> </div>
{/* Original URL */} <div> <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1"> Original URL </label> <a href={link.url} target="_blank" rel="noopener noreferrer" class="block text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 break-all transition-colors" > {link.url} </a> </div>
{/* Created Date */} <div> <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1"> Created </label> <div class="text-gray-700 dark:text-gray-300"> {formatDate(link.created_at)} </div> </div> </div>
{/* Back Link */} <a href="/" class="mt-8 text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors" > ← Create another link </a> </main> )}The stats page uses a loader function to fetch link data server-side before rendering. If the link doesn’t exist, it throws NotFoundError which returns a 404 response.
Step 13: Run the Development Server
Section titled “Step 13: Run the Development Server”Start the development server:
pnpm devVisit http://localhost:8787 to see your link shortener!
Testing the Application
Section titled “Testing the Application”- Shorten a URL: Enter a long URL and click “Shorten”
- Copy the link: Click “Copy” to copy the shortened URL
- View statistics: Click “View Stats” to see the stats page
- Test redirect: Open the short link in a new tab
- Verify clicks: Refresh the stats page to see the click count increase
- Test rate limiting: Submit more than 10 requests per minute
- Test 404: Visit
/stats/invalidto see 404 handling
Step 14: Build and Deploy
Section titled “Step 14: Build and Deploy”-
Build the application:
Terminal window pnpm build -
Apply migrations to production:
Terminal window wrangler d1 migrations apply linkly-db --remote -
Deploy to Cloudflare:
Terminal window pnpm deploy
Summary
Section titled “Summary”You’ve built a complete link shortener with:
- D1 Database: Persistent storage for links and click counts
- KV Cache: Fast redirect lookups with automatic TTL
- Rate Limiting: Protection against API abuse using
@cloudwerk/core/middleware - Click Analytics: Background tracking with
waitUntil - Statistics Page: Server-side rendered stats with loader functions
- Client Components: Interactive form with real-time feedback
Architecture Highlights
Section titled “Architecture Highlights”Caching Strategy
Section titled “Caching Strategy”The redirect route implements a read-through cache pattern:
- Check KV cache first (sub-millisecond latency)
- On cache miss, query D1 database
- Cache the result for future requests
Background Processing
Section titled “Background Processing”Using executionCtx.waitUntil() allows:
- Immediate response to the user
- Click tracking in the background
- Cache population without blocking
Rate Limiting
Section titled “Rate Limiting”The API middleware protects against abuse:
- 10 requests per minute per IP
- Uses the same KV namespace with a different prefix
- Returns standard rate limit headers
Next Steps
Section titled “Next Steps”- Database Guide - Learn more about D1 patterns
- Middleware - Advanced middleware patterns
- Authentication - Add user authentication
- Deployment - Production deployment options