← Back to articles

How to Build a URL Shortener (2026)

A URL shortener is a great learning project that covers databases, redirects, analytics, and API design. Here's how to build one.

Architecture

User visits: short.io/abc123
→ Server looks up "abc123" in database
→ Finds: https://example.com/very/long/url
→ Returns 301 redirect
→ Logs click analytics

Database Schema (Drizzle)

export const links = pgTable('links', {
  id: uuid('id').primaryKey().defaultRandom(),
  shortCode: text('short_code').notNull().unique(),
  url: text('url').notNull(),
  createdAt: timestamp('created_at').defaultNow(),
  clicks: integer('clicks').default(0),
  userId: text('user_id'), // Optional: link to user
})

export const clicks = pgTable('clicks', {
  id: uuid('id').primaryKey().defaultRandom(),
  linkId: uuid('link_id').references(() => links.id),
  timestamp: timestamp('timestamp').defaultNow(),
  referrer: text('referrer'),
  userAgent: text('user_agent'),
  country: text('country'),
  ip: text('ip'),
})

Create Short Link API

// app/api/links/route.ts
import { nanoid } from 'nanoid'

export async function POST(req: Request) {
  const { url } = await req.json()

  // Validate URL
  try { new URL(url) } catch {
    return Response.json({ error: 'Invalid URL' }, { status: 400 })
  }

  const shortCode = nanoid(7) // e.g., "abc1234"

  await db.insert(links).values({ shortCode, url })

  return Response.json({
    shortUrl: `${process.env.BASE_URL}/${shortCode}`,
    shortCode,
  })
}

Redirect Handler

// app/[code]/route.ts
export async function GET(req: Request, { params }: { params: { code: string } }) {
  const link = await db.query.links.findFirst({
    where: eq(links.shortCode, params.code),
  })

  if (!link) {
    return new Response('Not found', { status: 404 })
  }

  // Log click (async, don't block redirect)
  logClick(link.id, req).catch(console.error)

  // Increment counter
  db.update(links)
    .set({ clicks: sql`clicks + 1` })
    .where(eq(links.id, link.id))
    .execute()

  // 301 for permanent, 302 for temporary
  return Response.redirect(link.url, 301)
}

async function logClick(linkId: string, req: Request) {
  await db.insert(clicks).values({
    linkId,
    referrer: req.headers.get('referer'),
    userAgent: req.headers.get('user-agent'),
    country: req.headers.get('cf-ipcountry') || null,
  })
}

Frontend (React)

'use client'
import { useState } from 'react'

function URLShortener() {
  const [url, setUrl] = useState('')
  const [shortUrl, setShortUrl] = useState('')

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    const res = await fetch('/api/links', {
      method: 'POST',
      body: JSON.stringify({ url }),
    })
    const data = await res.json()
    setShortUrl(data.shortUrl)
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={url} onChange={e => setUrl(e.target.value)} placeholder="Paste URL..." />
      <button type="submit">Shorten</button>
      {shortUrl && (
        <div>
          <a href={shortUrl}>{shortUrl}</a>
          <button onClick={() => navigator.clipboard.writeText(shortUrl)}>Copy</button>
        </div>
      )}
    </form>
  )
}

Analytics Dashboard

// app/api/links/[code]/stats/route.ts
export async function GET(req: Request, { params }) {
  const link = await db.query.links.findFirst({
    where: eq(links.shortCode, params.code),
  })

  const clickData = await db
    .select({
      date: sql`DATE(timestamp)`,
      count: sql`COUNT(*)`,
    })
    .from(clicks)
    .where(eq(clicks.linkId, link.id))
    .groupBy(sql`DATE(timestamp)`)
    .orderBy(sql`DATE(timestamp)`)

  const topReferrers = await db
    .select({
      referrer: clicks.referrer,
      count: sql`COUNT(*)`,
    })
    .from(clicks)
    .where(eq(clicks.linkId, link.id))
    .groupBy(clicks.referrer)
    .orderBy(desc(sql`COUNT(*)`))
    .limit(10)

  return Response.json({ totalClicks: link.clicks, clickData, topReferrers })
}

Production Considerations

  1. Rate limiting — prevent abuse (100 links/hour per IP)
  2. URL validation — block malicious URLs
  3. Custom short codes — let users choose their own
  4. Expiration — optional TTL for links
  5. QR codes — generate QR code for each short link

FAQ

Should I build or use Dub.co?

Build for learning. Use Dub.co ($24/mo) for production — it includes analytics, custom domains, and team features.

What about collisions?

nanoid(7) gives 4.4 trillion possibilities. Collision probability is negligible. Add a unique constraint for safety.

How fast should redirects be?

<50ms. Use Redis cache for hot links. Database lookup for cold links.

Bottom Line

A URL shortener is a great project covering databases, redirects, analytics, and API design. Build it with Next.js + Drizzle + PostgreSQL. Add Redis caching for performance. For production use, consider Dub.co — it handles the edge cases you don't want to maintain.

Get AI tool guides in your inbox

Weekly deep-dives on the best AI coding tools, automation platforms, and productivity software.