Skip to main content
Back to Blog
Tutorials
4 min read
November 7, 2024

How to Build a URL Shortener with Next.js

Build a URL shortener with Next.js. Custom short codes, click tracking, expiring links, and QR codes.

Ryel Banfield

Founder & Lead Developer

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.

URL shortenerNext.jsredirectdatabasetutorial

Ready to Start Your Project?

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

Get in Touch

Related Articles