Build a URL Shortener in a Weekend — Your First Real Project
The fastest way to learn is to ship something real. This weekend project teaches HTTP, databases, hashing, and deployment — all in one go.
A URL shortener is deceptively simple on the surface: take a long URL, return a short code, redirect visitors who hit that code. But underneath that simplicity lives a surprisingly rich set of engineering problems — slug collision handling, database design, HTTP redirect semantics, and production deployment. This is why it makes such a perfect first real project.
By the end of this guide you will have a working, deployed URL shortener with a minimal frontend, a Next.js API backend, and a Postgres (or SQLite for local dev) database. Let's build it.
1. Project Setup
Bootstrap the project with the Next.js App Router:
bash1npx create-next-app@latest url-shortener --typescript --tailwind --eslint --app2cd url-shortener3npm install nanoid better-sqlite34npm install -D @types/better-sqlite3
For local development we'll use SQLite via better-sqlite3 — it's a single file, zero config, and plenty fast for a weekend project. For production on Vercel we'll swap to Postgres via @vercel/postgres.
Create the database initialisation module:
typescript1// lib/db.ts2import Database from 'better-sqlite3'3import path from 'path'45const DB_PATH = path.join(process.cwd(), 'data', 'links.db')67let db: Database.Database89export function getDb(): Database.Database {10 if (!db) {11 db = new Database(DB_PATH)12 db.pragma('journal_mode = WAL') // Better concurrent read performance13 db.exec(`14 CREATE TABLE IF NOT EXISTS links (15 id INTEGER PRIMARY KEY AUTOINCREMENT,16 slug TEXT NOT NULL UNIQUE,17 url TEXT NOT NULL,18 hits INTEGER NOT NULL DEFAULT 0,19 created_at TEXT NOT NULL DEFAULT (datetime('now'))20 )21 `)22 }23 return db24}
Why WAL mode? Write-Ahead Logging lets readers and writers work concurrently without blocking each other. Always set this on SQLite in any networked context.
2. The Shortening Algorithm
The core of a URL shortener is generating a short, unique, URL-safe slug. The naive approach — random characters — works, but you need to handle collisions. Enter nanoid.
typescript1// lib/shorten.ts2import { nanoid } from 'nanoid'3import { getDb } from './db'45const SLUG_LENGTH = 7 // 7 chars gives ~1.5 trillion combinations at this alphabet size67export function createShortLink(url: string): string {8 const db = getDb()910 // Validate the URL before storing it11 try {12 new URL(url)13 } catch {14 throw new Error('Invalid URL — must include scheme (https://)')15 }1617 // Check if this exact URL was already shortened — return existing slug18 const existing = db19 .prepare('SELECT slug FROM links WHERE url = ?')20 .get(url) as { slug: string } | undefined2122 if (existing) return existing.slug2324 // Generate a unique slug with collision retry25 let slug: string26 let attempts = 02728 do {29 if (attempts > 10) throw new Error('Failed to generate unique slug after 10 attempts')30 slug = nanoid(SLUG_LENGTH)31 attempts++32 } while (db.prepare('SELECT id FROM links WHERE slug = ?').get(slug))3334 db.prepare('INSERT INTO links (slug, url) VALUES (?, ?)').run(slug, url)3536 return slug37}3839export function resolveSlug(slug: string): string | null {40 const db = getDb()41 const row = db42 .prepare('SELECT url FROM links WHERE slug = ?')43 .get(slug) as { url: string } | undefined4445 if (!row) return null4647 // Increment hit counter — fire and forget, we don't care if this fails48 db.prepare('UPDATE links SET hits = hits + 1 WHERE slug = ?').run(slug)4950 return row.url51}
Why nanoid over Math.random()? nanoid uses the crypto module internally, producing cryptographically random output. It's also URL-safe by default and significantly faster than UUID.
3. The API Routes
Two endpoints handle the core logic: one to create a short link and one to resolve it.
typescript1// app/api/shorten/route.ts2import { NextRequest, NextResponse } from 'next/server'3import { createShortLink } from '@/lib/shorten'45export async function POST(req: NextRequest) {6 try {7 const body = await req.json()8 const { url } = body910 if (!url || typeof url !== 'string') {11 return NextResponse.json({ error: 'url is required' }, { status: 400 })12 }1314 const slug = createShortLink(url)15 const host = req.headers.get('host') ?? 'localhost:3000'16 const protocol = host.startsWith('localhost') ? 'http' : 'https'17 const shortUrl = `${protocol}://${host}/${slug}`1819 return NextResponse.json({ slug, shortUrl }, { status: 201 })20 } catch (err) {21 const message = err instanceof Error ? err.message : 'Internal server error'22 return NextResponse.json({ error: message }, { status: 500 })23 }24}
The redirect handler lives in a dynamic route segment:
typescript1// app/[slug]/route.ts2import { NextRequest, NextResponse } from 'next/server'3import { resolveSlug } from '@/lib/shorten'45export async function GET(6 _req: NextRequest,7 { params }: { params: { slug: string } }8) {9 const destination = resolveSlug(params.slug)1011 if (!destination) {12 // Return a 404 page rather than a bare JSON error13 return NextResponse.redirect(new URL('/not-found', _req.url), { status: 302 })14 }1516 // 301 = permanent redirect (browser/CDN caches it)17 // 302 = temporary (allows you to change the destination later)18 // For analytics to work correctly, use 302 — 301 means repeat visitors skip your server19 return NextResponse.redirect(destination, { status: 302 })20}
HTTP redirect semantics matter. A 301 tells browsers and CDNs to cache the redirect indefinitely. If you ever want to update where a slug points, you will regret using 301. Use 302 unless you have a specific reason not to.
4. The Frontend
Keep it minimal. One input, one button, one result.
tsx1// app/page.tsx2'use client'34import { useState } from 'react'56export default function Home() {7 const [url, setUrl] = useState('')8 const [result, setResult] = useState<{ shortUrl: string; slug: string } | null>(null)9 const [error, setError] = useState<string | null>(null)10 const [loading, setLoading] = useState(false)1112 async function handleSubmit(e: React.FormEvent) {13 e.preventDefault()14 setLoading(true)15 setError(null)16 setResult(null)1718 try {19 const res = await fetch('/api/shorten', {20 method: 'POST',21 headers: { 'Content-Type': 'application/json' },22 body: JSON.stringify({ url }),23 })2425 const data = await res.json()2627 if (!res.ok) throw new Error(data.error ?? 'Something went wrong')2829 setResult(data)30 } catch (err) {31 setError(err instanceof Error ? err.message : 'Unknown error')32 } finally {33 setLoading(false)34 }35 }3637 return (38 <main className="min-h-screen flex items-center justify-center bg-gray-950 text-white">39 <div className="w-full max-w-lg px-6">40 <h1 className="text-3xl font-bold mb-2">URL Shortener</h1>41 <p className="text-gray-400 mb-8">Paste a long URL. Get a short one.</p>4243 <form onSubmit={handleSubmit} className="flex gap-2">44 <input45 type="url"46 required47 placeholder="https://example.com/very/long/path"48 value={url}49 onChange={e => setUrl(e.target.value)}50 className="flex-1 rounded-lg px-4 py-2.5 bg-gray-800 border border-gray-700 focus:outline-none focus:border-indigo-500"51 />52 <button53 type="submit"54 disabled={loading}55 className="px-5 py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 font-semibold transition-colors"56 >57 {loading ? 'Shortening…' : 'Shorten'}58 </button>59 </form>6061 {error && <p className="mt-4 text-red-400 text-sm">{error}</p>}6263 {result && (64 <div className="mt-6 p-4 rounded-lg bg-gray-800 border border-gray-700">65 <p className="text-sm text-gray-400 mb-1">Your short link</p>66 <a67 href={result.shortUrl}68 target="_blank"69 rel="noopener noreferrer"70 className="text-indigo-400 hover:underline break-all font-mono text-lg"71 >72 {result.shortUrl}73 </a>74 <button75 onClick={() => navigator.clipboard.writeText(result.shortUrl)}76 className="mt-3 block text-xs text-gray-500 hover:text-white transition-colors"77 >78 Copy to clipboard79 </button>80 </div>81 )}82 </div>83 </main>84 )85}
5. Deploying to Vercel
SQLite doesn't work on Vercel's serverless runtime (the filesystem is ephemeral). Swap to Vercel Postgres for production:
bash1npm install @vercel/postgres2vercel link3vercel env pull .env.local # pulls POSTGRES_URL and friends automatically
Then update lib/db.ts to branch on environment:
typescript1// lib/db.ts (production branch)2import { sql } from '@vercel/postgres'34export async function initDb() {5 await sql`6 CREATE TABLE IF NOT EXISTS links (7 id SERIAL PRIMARY KEY,8 slug TEXT NOT NULL UNIQUE,9 url TEXT NOT NULL,10 hits INTEGER NOT NULL DEFAULT 0,11 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()12 )13 `14}
Push to GitHub, connect the repo to Vercel, add the Postgres integration from the Vercel dashboard, and deploy. The whole thing should be live in under five minutes.
What You Learned
Building this project exercises more engineering fundamentals than most tutorials:
- HTTP semantics — the difference between 301 and 302 actually matters in production
- Database schema design — even a two-column table has real decisions (UNIQUE constraint, WAL mode)
- Collision-safe ID generation — why crypto random > Math.random
- API design — proper error codes, input validation, and response shapes
- Environment-aware code — handling dev vs. production differences cleanly
- Deployment — connecting a database to a serverless function in production
Ship it, put the URL in your portfolio, and move on to something harder.

Kevin Mbugua Kiarie
Senior Fullstack Developer
Five years building full-stack systems in TypeScript, Elixir, and Python. Writing about engineering, architecture, and the craft of shipping software that lasts.