A URL shortener is a great weekend project. Here is how to build one with click tracking and analytics.
Step 1: Database Schema
// db/schema.ts
import { pgTable, text, timestamp, integer, uuid } from "drizzle-orm/pg-core";
export const links = pgTable("links", {
id: uuid("id").primaryKey().defaultRandom(),
shortCode: text("short_code").notNull().unique(),
originalUrl: text("original_url").notNull(),
clicks: integer("clicks").default(0),
createdAt: timestamp("created_at").defaultNow(),
expiresAt: timestamp("expires_at"),
});
export const clickEvents = pgTable("click_events", {
id: uuid("id").primaryKey().defaultRandom(),
linkId: uuid("link_id")
.notNull()
.references(() => links.id),
referrer: text("referrer"),
userAgent: text("user_agent"),
country: text("country"),
clickedAt: timestamp("clicked_at").defaultNow(),
});
Step 2: Generate Short Codes
// lib/short-code.ts
const ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
export function generateShortCode(length = 6): string {
let result = "";
const bytes = crypto.getRandomValues(new Uint8Array(length));
for (const byte of bytes) {
result += ALPHABET[byte % ALPHABET.length];
}
return result;
}
Step 3: Create Link API
// app/api/links/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
import { links } from "@/db/schema";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { generateShortCode } from "@/lib/short-code";
const createSchema = z.object({
url: z.string().url(),
customCode: z.string().min(3).max(20).optional(),
expiresIn: z.number().optional(), // hours
});
export async function POST(req: NextRequest) {
const body = await req.json();
const parsed = createSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.flatten() },
{ status: 400 }
);
}
const { url, customCode, expiresIn } = parsed.data;
// Check custom code availability
if (customCode) {
const existing = await db
.select()
.from(links)
.where(eq(links.shortCode, customCode))
.limit(1);
if (existing.length > 0) {
return NextResponse.json(
{ error: "Custom code already taken" },
{ status: 409 }
);
}
}
const shortCode = customCode || generateShortCode();
const expiresAt = expiresIn
? new Date(Date.now() + expiresIn * 60 * 60 * 1000)
: null;
const [link] = await db
.insert(links)
.values({
shortCode,
originalUrl: url,
expiresAt,
})
.returning();
return NextResponse.json({
shortUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/${shortCode}`,
shortCode: link.shortCode,
originalUrl: link.originalUrl,
});
}
Step 4: Redirect Route
// app/[code]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
import { links, clickEvents } from "@/db/schema";
import { eq, sql } from "drizzle-orm";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ code: string }> }
) {
const { code } = await params;
const [link] = await db
.select()
.from(links)
.where(eq(links.shortCode, code))
.limit(1);
if (!link) {
return NextResponse.redirect(new URL("/404", req.url));
}
// Check expiration
if (link.expiresAt && new Date() > link.expiresAt) {
return NextResponse.redirect(new URL("/expired", req.url));
}
// Track click (non-blocking)
const headers = req.headers;
db.insert(clickEvents)
.values({
linkId: link.id,
referrer: headers.get("referer"),
userAgent: headers.get("user-agent"),
country: headers.get("x-vercel-ip-country") || null,
})
.then(() =>
db
.update(links)
.set({ clicks: sql`${links.clicks} + 1` })
.where(eq(links.id, link.id))
);
return NextResponse.redirect(link.originalUrl, { status: 301 });
}
Step 5: Create Link Form
"use client";
import { useState } from "react";
export function ShortenForm() {
const [url, setUrl] = useState("");
const [customCode, setCustomCode] = useState("");
const [result, setResult] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
setResult(null);
try {
const res = await fetch("/api/links", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url,
customCode: customCode || undefined,
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to create link");
}
const data = await res.json();
setResult(data.shortUrl);
setUrl("");
setCustomCode("");
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
} finally {
setLoading(false);
}
}
return (
<div className="mx-auto max-w-lg">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium">URL to shorten</label>
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com/very/long/url"
required
type="url"
className="w-full rounded-lg border px-4 py-2.5 text-sm dark:border-gray-600 dark:bg-gray-800"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium">
Custom code (optional)
</label>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">rcbsoftware.com/</span>
<input
value={customCode}
onChange={(e) => setCustomCode(e.target.value)}
placeholder="my-link"
className="flex-1 rounded-lg border px-3 py-2.5 text-sm dark:border-gray-600 dark:bg-gray-800"
/>
</div>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-blue-600 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? "Creating..." : "Shorten URL"}
</button>
</form>
{result && (
<div className="mt-4 flex items-center gap-2 rounded-lg border bg-green-50 p-4 dark:border-green-800 dark:bg-green-900/20">
<input
readOnly
value={result}
className="flex-1 bg-transparent text-sm font-medium text-green-700 outline-none dark:text-green-400"
/>
<button
onClick={() => {
navigator.clipboard.writeText(result);
}}
className="rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-green-700"
>
Copy
</button>
</div>
)}
</div>
);
}
Step 6: Analytics Dashboard
// app/api/links/[code]/stats/route.ts
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ code: string }> }
) {
const { code } = await params;
const [link] = await db
.select()
.from(links)
.where(eq(links.shortCode, code))
.limit(1);
if (!link) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const clicks = await db
.select()
.from(clickEvents)
.where(eq(clickEvents.linkId, link.id))
.orderBy(desc(clickEvents.clickedAt))
.limit(100);
// Aggregate by country
const byCountry = clicks.reduce(
(acc, click) => {
const country = click.country || "Unknown";
acc[country] = (acc[country] || 0) + 1;
return acc;
},
{} as Record<string, number>
);
return NextResponse.json({
link,
totalClicks: link.clicks,
recentClicks: clicks,
byCountry,
});
}
Step 7: QR Code Generation
pnpm add qrcode
pnpm add -D @types/qrcode
"use client";
import QRCode from "qrcode";
import { useEffect, useRef } from "react";
export function QRCodeDisplay({ url }: { url: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (canvasRef.current) {
QRCode.toCanvas(canvasRef.current, url, {
width: 200,
margin: 2,
color: { dark: "#000", light: "#fff" },
});
}
}, [url]);
return (
<div className="inline-block rounded-lg border p-4">
<canvas ref={canvasRef} />
<p className="mt-2 text-center text-xs text-gray-500">{url}</p>
</div>
);
}
Need Custom Web Tools?
We build web applications with analytics, URL management, and custom business tools. Contact us to discuss your idea.