Skip to main content
Back to Blog
Tutorials
5 min read
December 3, 2024

How to Build a URL Shortener in Next.js

Create a URL shortening service with custom slugs, click tracking, QR codes, and analytics dashboard in Next.js.

Ryel Banfield

Founder & Lead Developer

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.

URL shortenerlinksanalyticsNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles