Build a URL Shortener in a Weekend — Your First Real Project
Development

Build a URL Shortener in a Weekend — Your First Real Project

November 15, 2024
7 min read

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:

bash
1npx create-next-app@latest url-shortener --typescript --tailwind --eslint --app
2cd url-shortener
3npm install nanoid better-sqlite3
4npm 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:

typescript
1// lib/db.ts
2import Database from 'better-sqlite3'
3import path from 'path'
4
5const DB_PATH = path.join(process.cwd(), 'data', 'links.db')
6
7let db: Database.Database
8
9export function getDb(): Database.Database {
10 if (!db) {
11 db = new Database(DB_PATH)
12 db.pragma('journal_mode = WAL') // Better concurrent read performance
13 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 db
24}

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.

typescript
1// lib/shorten.ts
2import { nanoid } from 'nanoid'
3import { getDb } from './db'
4
5const SLUG_LENGTH = 7 // 7 chars gives ~1.5 trillion combinations at this alphabet size
6
7export function createShortLink(url: string): string {
8 const db = getDb()
9
10 // Validate the URL before storing it
11 try {
12 new URL(url)
13 } catch {
14 throw new Error('Invalid URL — must include scheme (https://)')
15 }
16
17 // Check if this exact URL was already shortened — return existing slug
18 const existing = db
19 .prepare('SELECT slug FROM links WHERE url = ?')
20 .get(url) as { slug: string } | undefined
21
22 if (existing) return existing.slug
23
24 // Generate a unique slug with collision retry
25 let slug: string
26 let attempts = 0
27
28 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))
33
34 db.prepare('INSERT INTO links (slug, url) VALUES (?, ?)').run(slug, url)
35
36 return slug
37}
38
39export function resolveSlug(slug: string): string | null {
40 const db = getDb()
41 const row = db
42 .prepare('SELECT url FROM links WHERE slug = ?')
43 .get(slug) as { url: string } | undefined
44
45 if (!row) return null
46
47 // Increment hit counter — fire and forget, we don't care if this fails
48 db.prepare('UPDATE links SET hits = hits + 1 WHERE slug = ?').run(slug)
49
50 return row.url
51}

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.

typescript
1// app/api/shorten/route.ts
2import { NextRequest, NextResponse } from 'next/server'
3import { createShortLink } from '@/lib/shorten'
4
5export async function POST(req: NextRequest) {
6 try {
7 const body = await req.json()
8 const { url } = body
9
10 if (!url || typeof url !== 'string') {
11 return NextResponse.json({ error: 'url is required' }, { status: 400 })
12 }
13
14 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}`
18
19 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:

typescript
1// app/[slug]/route.ts
2import { NextRequest, NextResponse } from 'next/server'
3import { resolveSlug } from '@/lib/shorten'
4
5export async function GET(
6 _req: NextRequest,
7 { params }: { params: { slug: string } }
8) {
9 const destination = resolveSlug(params.slug)
10
11 if (!destination) {
12 // Return a 404 page rather than a bare JSON error
13 return NextResponse.redirect(new URL('/not-found', _req.url), { status: 302 })
14 }
15
16 // 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 server
19 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.

tsx
1// app/page.tsx
2'use client'
3
4import { useState } from 'react'
5
6export 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)
11
12 async function handleSubmit(e: React.FormEvent) {
13 e.preventDefault()
14 setLoading(true)
15 setError(null)
16 setResult(null)
17
18 try {
19 const res = await fetch('/api/shorten', {
20 method: 'POST',
21 headers: { 'Content-Type': 'application/json' },
22 body: JSON.stringify({ url }),
23 })
24
25 const data = await res.json()
26
27 if (!res.ok) throw new Error(data.error ?? 'Something went wrong')
28
29 setResult(data)
30 } catch (err) {
31 setError(err instanceof Error ? err.message : 'Unknown error')
32 } finally {
33 setLoading(false)
34 }
35 }
36
37 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>
42
43 <form onSubmit={handleSubmit} className="flex gap-2">
44 <input
45 type="url"
46 required
47 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 <button
53 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>
60
61 {error && <p className="mt-4 text-red-400 text-sm">{error}</p>}
62
63 {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 <a
67 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 <button
75 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 clipboard
79 </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:

bash
1npm install @vercel/postgres
2vercel link
3vercel env pull .env.local # pulls POSTGRES_URL and friends automatically

Then update lib/db.ts to branch on environment:

typescript
1// lib/db.ts (production branch)
2import { sql } from '@vercel/postgres'
3
4export 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.

Tags
Next.jsTypeScriptSQLitePostgresVercelAPI Designnanoid
Kevin Mbugua Kiarie

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.

Build a URL Shortener in a Weekend — Your First Real Project · Kevin Kiarie