A URL shortener creates compact links that redirect to longer URLs while tracking clicks and analytics.
Step 1: Database Schema
// db/schema.ts
import { pgTable, text, timestamp, uuid, integer, boolean } from "drizzle-orm/pg-core";
export const shortLinks = pgTable("short_links", {
id: uuid("id").defaultRandom().primaryKey(),
slug: text("slug").unique().notNull(),
url: text("url").notNull(),
title: text("title"),
userId: text("user_id"),
clicks: integer("clicks").default(0),
active: boolean("active").default(true),
expiresAt: timestamp("expires_at"),
createdAt: timestamp("created_at").defaultNow(),
});
export const clickEvents = pgTable("click_events", {
id: uuid("id").defaultRandom().primaryKey(),
linkId: uuid("link_id").references(() => shortLinks.id).notNull(),
referrer: text("referrer"),
userAgent: text("user_agent"),
country: text("country"),
city: text("city"),
device: text("device"),
browser: text("browser"),
os: text("os"),
createdAt: timestamp("created_at").defaultNow(),
});
Step 2: Slug Generation
// lib/short-links.ts
import { db } from "@/db";
import { shortLinks } from "@/db/schema";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";
export async function generateSlug(customSlug?: string): Promise<string> {
if (customSlug) {
// Validate custom slug
if (!/^[a-zA-Z0-9_-]{3,50}$/.test(customSlug)) {
throw new Error("Slug must be 3-50 characters (letters, numbers, hyphens, underscores)");
}
const existing = await db
.select({ id: shortLinks.id })
.from(shortLinks)
.where(eq(shortLinks.slug, customSlug));
if (existing.length > 0) {
throw new Error("This slug is already taken");
}
return customSlug;
}
// Generate random slug, retry if collision
for (let i = 0; i < 5; i++) {
const slug = nanoid(7);
const existing = await db
.select({ id: shortLinks.id })
.from(shortLinks)
.where(eq(shortLinks.slug, slug));
if (existing.length === 0) return slug;
}
throw new Error("Failed to generate unique slug");
}
Step 3: Create Short Link API
// app/api/links/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
import { shortLinks } from "@/db/schema";
import { generateSlug } from "@/lib/short-links";
import { desc, eq } from "drizzle-orm";
import { z } from "zod";
const createSchema = z.object({
url: z.string().url(),
slug: z.string().optional(),
title: z.string().max(100).optional(),
expiresAt: z.string().datetime().optional(),
});
export async function POST(request: NextRequest) {
const body = await request.json();
const parsed = createSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
try {
const slug = await generateSlug(parsed.data.slug);
const [link] = await db
.insert(shortLinks)
.values({
slug,
url: parsed.data.url,
title: parsed.data.title,
expiresAt: parsed.data.expiresAt ? new Date(parsed.data.expiresAt) : undefined,
})
.returning();
const shortUrl = `${process.env.NEXT_PUBLIC_APP_URL}/${link.slug}`;
return NextResponse.json({ link, shortUrl }, { status: 201 });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to create link" },
{ status: 400 }
);
}
}
export async function GET() {
const links = await db
.select()
.from(shortLinks)
.orderBy(desc(shortLinks.createdAt))
.limit(50);
return NextResponse.json({ links });
}
Step 4: Redirect Route with Click Tracking
// app/[slug]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
import { shortLinks, clickEvents } from "@/db/schema";
import { eq, sql } from "drizzle-orm";
function parseUserAgent(ua: string) {
let device = "desktop";
if (/mobile/i.test(ua)) device = "mobile";
else if (/tablet/i.test(ua)) device = "tablet";
let browser = "other";
if (/chrome/i.test(ua) && !/edge/i.test(ua)) browser = "chrome";
else if (/firefox/i.test(ua)) browser = "firefox";
else if (/safari/i.test(ua) && !/chrome/i.test(ua)) browser = "safari";
else if (/edge/i.test(ua)) browser = "edge";
let os = "other";
if (/windows/i.test(ua)) os = "windows";
else if (/mac/i.test(ua)) os = "macos";
else if (/linux/i.test(ua)) os = "linux";
else if (/android/i.test(ua)) os = "android";
else if (/iphone|ipad/i.test(ua)) os = "ios";
return { device, browser, os };
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const [link] = await db
.select()
.from(shortLinks)
.where(eq(shortLinks.slug, slug));
if (!link || !link.active) {
return NextResponse.redirect(new URL("/404", request.url));
}
if (link.expiresAt && link.expiresAt < new Date()) {
return NextResponse.redirect(new URL("/expired", request.url));
}
// Track click asynchronously
const userAgent = request.headers.get("user-agent") ?? "";
const referrer = request.headers.get("referer") ?? undefined;
const { device, browser, os } = parseUserAgent(userAgent);
// Fire and forget
Promise.all([
db.insert(clickEvents).values({
linkId: link.id,
referrer,
userAgent,
device,
browser,
os,
}),
db
.update(shortLinks)
.set({ clicks: sql`${shortLinks.clicks} + 1` })
.where(eq(shortLinks.id, link.id)),
]).catch(console.error);
return NextResponse.redirect(link.url, 307);
}
Step 5: Analytics API
// app/api/links/[id]/analytics/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
import { clickEvents } from "@/db/schema";
import { eq, sql, gte, desc } from "drizzle-orm";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const days = Number(new URL(request.url).searchParams.get("days") ?? 30);
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
// Clicks by day
const clicksByDay = await db
.select({
date: sql<string>`DATE(${clickEvents.createdAt})`.as("date"),
count: sql<number>`COUNT(*)`.as("count"),
})
.from(clickEvents)
.where(eq(clickEvents.linkId, id))
.groupBy(sql`DATE(${clickEvents.createdAt})`)
.orderBy(desc(sql`DATE(${clickEvents.createdAt})`));
// Top referrers
const topReferrers = await db
.select({
referrer: clickEvents.referrer,
count: sql<number>`COUNT(*)`.as("count"),
})
.from(clickEvents)
.where(eq(clickEvents.linkId, id))
.groupBy(clickEvents.referrer)
.orderBy(desc(sql`COUNT(*)`))
.limit(10);
// Device breakdown
const devices = await db
.select({
device: clickEvents.device,
count: sql<number>`COUNT(*)`.as("count"),
})
.from(clickEvents)
.where(eq(clickEvents.linkId, id))
.groupBy(clickEvents.device);
// Browser breakdown
const browsers = await db
.select({
browser: clickEvents.browser,
count: sql<number>`COUNT(*)`.as("count"),
})
.from(clickEvents)
.where(eq(clickEvents.linkId, id))
.groupBy(clickEvents.browser);
return NextResponse.json({
clicksByDay,
topReferrers,
devices,
browsers,
});
}
Step 6: Link Creation Form
// components/links/CreateLinkForm.tsx
"use client";
import { useState } from "react";
export function CreateLinkForm({ onCreated }: { onCreated: () => void }) {
const [url, setUrl] = useState("");
const [slug, setSlug] = useState("");
const [title, setTitle] = useState("");
const [result, setResult] = useState<{ shortUrl: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
setResult(null);
const res = await fetch("/api/links", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url,
slug: slug || undefined,
title: title || undefined,
}),
});
const data = await res.json();
if (res.ok) {
setResult({ shortUrl: data.shortUrl });
setUrl("");
setSlug("");
setTitle("");
onCreated();
} else {
setError(data.error ?? "Failed to create link");
}
setLoading(false);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="url" className="mb-1 block text-sm font-medium">URL</label>
<input
id="url"
type="url"
required
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com/very-long-url-here"
className="w-full rounded-lg border px-3 py-2"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="slug" className="mb-1 block text-sm font-medium">
Custom slug (optional)
</label>
<input
id="slug"
type="text"
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="my-link"
className="w-full rounded-lg border px-3 py-2"
/>
</div>
<div>
<label htmlFor="title" className="mb-1 block text-sm font-medium">
Title (optional)
</label>
<input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="My Marketing Campaign"
className="w-full rounded-lg border px-3 py-2"
/>
</div>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className="rounded-lg bg-blue-600 px-6 py-2.5 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? "Creating..." : "Shorten URL"}
</button>
{result && (
<div className="rounded-lg border border-green-200 bg-green-50 p-4">
<p className="text-sm font-medium text-green-800">Short URL created:</p>
<div className="mt-1 flex items-center gap-2">
<code className="text-sm">{result.shortUrl}</code>
<button
type="button"
onClick={() => navigator.clipboard.writeText(result.shortUrl)}
className="text-sm text-green-700 hover:underline"
>
Copy
</button>
</div>
</div>
)}
</form>
);
}
Need Custom Link Management?
We build link shorteners, UTM tracking tools, and analytics platforms for marketing teams. Contact us to get started.