Build a URL Shortener with Next.js and Supabase

10 min readnextjs, supabase, tutorial

Every short link on this site runs through a shortener I built myself: this domain is both the blog and a personal link shortener served from a single Next.js app. This post walks through the entire build — a Postgres schema on Supabase, a catch-all redirect route in the App Router, click analytics captured on every redirect, and deployment on Vercel. The code samples are lifted from the implementation that serves this page, simplified where the details are noise but faithful to what actually runs in production.

By the end you will know how to model links and clicks in Postgres, why slug validation and reserved slugs matter more than they first appear, how to look up a link with the Supabase service-role client inside a dynamic route, and how to record the click and issue the redirect without adding meaningful latency. Nothing here needs a paid plan — the free tiers of Supabase and Vercel cover a personal shortener comfortably.

The whole thing is two tables, three small library functions, and one route file. If you already have a Next.js site, you can bolt it on in an afternoon.

Why run your own link shortener

Third-party shorteners are convenient until they are not. You rent the domain, you rent the analytics, and if the service changes pricing or shuts down, every link you ever shared breaks. Running your own gives you a branded domain, the ability to edit a destination after the link is already printed on something, and — the part I care most about — click data in your own Postgres database, queryable with plain SQL instead of whatever a vendor dashboard exposes.

If you are hesitating because you have heard shorteners are bad for search rankings, that concern is mostly overstated — I dug into the details in Do URL Shorteners Hurt SEO?. For a personal shortener pointing at your own content, it is a non-issue.

Designing the schema: links, clicks, and reserved slugs

Two tables carry the whole system. The links table is the source of truth for every short link, and the clicks table gets one row per redirect. Here is the core of the migration, trimmed to the essential columns:

create extension if not exists pgcrypto;

create table public.links (
  id uuid primary key default gen_random_uuid(),
  slug text not null unique
    check (slug ~ '^[a-z0-9][a-z0-9_-]{0,63}$'),
  destination_url text not null
    check (destination_url ~* '^https?://'),
  forward_query boolean not null default true,
  is_active boolean not null default true,
  expires_at timestamptz,
  click_limit integer
    check (click_limit is null or click_limit > 0),
  append_utm jsonb,
  created_by uuid references auth.users(id)
    on delete cascade not null,
  created_at timestamptz not null default now()
);

create table public.clicks (
  id bigserial primary key,
  link_id uuid references public.links(id)
    on delete cascade not null,
  slug text not null,
  clicked_at timestamptz not null default now(),
  ip_hash text,
  country text,
  city text,
  device_type text,
  browser text,
  os text,
  referrer text,
  gclid text,
  utm_source text,
  utm_medium text,
  utm_campaign text,
  is_bot boolean not null default false
);

create index clicks_link_id_time_idx
  on public.clicks (link_id, clicked_at desc);

A few decisions worth calling out. The slug has a check constraint that mirrors the application-level regex, so garbage can never enter the table even if a future admin tool has a bug. The operational flags — is_active, expires_at, click_limit — cost nothing now and save a migration later when you want to kill or cap a link. The clicks table denormalizes the slug alongside link_id so analytics queries read naturally, and it stores an ip_hash rather than a raw IP: hashed with a server-side secret, it is good enough for counting unique visitors without keeping personal data around.

There is also a tiny reserved_slugs table plus a trigger that rejects inserts matching a reserved path. Because the redirect route lives at the root of the site, a link with the slug blog or robots.txt would shadow a real page. The trigger is the database-side backstop:

create table public.reserved_slugs (slug text primary key);

insert into public.reserved_slugs (slug) values
  ('admin'), ('api'), ('login'), ('blog'), ('robots.txt');

create or replace function public.reject_reserved_slug()
returns trigger language plpgsql as $$
begin
  if exists (
    select 1 from public.reserved_slugs
    where slug = new.slug
  ) then
    raise exception 'slug % is reserved', new.slug;
  end if;
  return new;
end $$;

create trigger links_reject_reserved
  before insert or update of slug on public.links
  for each row execute function public.reject_reserved_slug();

Row Level Security

Both tables have Row Level Security enabled. Owners get full access to their own links and read access to the clicks on those links; nobody else gets anything. Click inserts happen through the service-role client, which bypasses RLS entirely — so no insert policy is needed at all:

alter table public.links enable row level security;
alter table public.clicks enable row level security;

create policy links_owner_all on public.links
  for all to authenticated
  using (auth.uid() = created_by)
  with check (auth.uid() = created_by);

create policy clicks_owner_read on public.clicks
  for select to authenticated
  using (exists (
    select 1 from public.links l
    where l.id = clicks.link_id
      and l.created_by = auth.uid()
  ));

This owner-scoped pattern generalizes well beyond shorteners; I wrote up the variants I keep reusing in Supabase Row Level Security: Practical Patterns That Scale.

Slug validation and generation with nanoid

The slug helper is small enough to show in full. Generated slugs come from nanoid with a custom 32-character alphabet that drops lookalike characters — no l, o, 0, or 1 — because short links get read aloud and retyped from photos:

import { customAlphabet } from "nanoid";

const ALPHABET = "abcdefghijkmnpqrstuvwxyz23456789";
const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;

export const generateSlug = customAlphabet(ALPHABET, 6);

export function isValidSlug(slug: string): boolean {
  return SLUG_RE.test(slug);
}

export const RESERVED_SLUGS = new Set([
  "admin", "api", "auth", "login", "blog",
  "_next", "favicon.ico", "robots.txt", "sitemap.xml",
]);

export function isReservedSlug(slug: string): boolean {
  return RESERVED_SLUGS.has(slug.toLowerCase());
}

Six characters from a 32-character alphabet gives around a billion combinations — collisions are a rounding error at personal scale, and the unique constraint on the table catches them anyway. The regex allows custom slugs too: lowercase alphanumeric start, then up to 64 total characters of letters, digits, hyphens, and underscores.

Notice the reserved list exists in both the app and the database. The app copy lets the redirect route return a 404 for /api-style paths without a database round trip, and it can include Next.js-specific entries like opengraph-image that the database does not need to know about. The trigger copy guarantees integrity no matter how a row gets inserted. Duplication here is a feature, not an accident.

The catch-all redirect route

The redirect handler is a dynamic [slug] segment at the root of the App Router, so /abc123 resolves to it while /blog and friends match their own static routes first. The one line that matters most is the force-dynamic segment config — a redirect route must run on every single request, because every hit needs to be counted, expiry needs to be checked in real time, and edits to the destination need to take effect immediately. Caching any part of this defeats the purpose.

import { notFound, redirect } from "next/navigation";
import { headers } from "next/headers";
import { createSupabaseAdminClient } from "@/lib/supabase/admin";
import { buildDestinationUrl } from "@/lib/redirect-url";
import { isReservedSlug, isValidSlug } from "@/lib/slug";
import { recordClick } from "@/lib/record-click";

export const dynamic = "force-dynamic";

export default async function SlugRedirect(props: {
  params: Promise<{ slug: string }>;
  searchParams: Promise<
    Record<string, string | string[] | undefined>
  >;
}) {
  const { slug: rawSlug } = await props.params;
  const slug = rawSlug.toLowerCase();

  if (!isValidSlug(slug) || isReservedSlug(slug)) notFound();

  const admin = createSupabaseAdminClient();
  const { data: link, error } = await admin
    .from("links")
    .select(
      "id, slug, destination_url, forward_query, " +
        "is_active, expires_at, click_limit, append_utm",
    )
    .eq("slug", slug)
    .maybeSingle();

  if (error || !link) notFound();
  if (!link.is_active) notFound();
  if (link.expires_at && new Date(link.expires_at) < new Date())
    notFound();

  if (link.click_limit !== null) {
    const { count } = await admin
      .from("clicks")
      .select("*", { count: "exact", head: true })
      .eq("link_id", link.id);
    if ((count ?? 0) >= link.click_limit) notFound();
  }

  const searchParams = await props.searchParams;
  const sp = new URLSearchParams();
  for (const [k, v] of Object.entries(searchParams)) {
    if (v === undefined) continue;
    if (Array.isArray(v)) v.forEach((x) => sp.append(k, x));
    else sp.set(k, v);
  }

  const destination = buildDestinationUrl(
    link.destination_url,
    sp.toString(),
    link.forward_query,
    link.append_utm,
  );

  const hdrs = await headers();
  await recordClick({ link, headers: hdrs, searchParams: sp });

  redirect(destination);
}

The order of operations is deliberate. Validation and the reserved-slug check run before any database work, so junk requests are cheap. The lookup uses the service-role client because the visitor clicking a short link is anonymous — RLS would (correctly) show them nothing, so the server has to act with elevated rights on their behalf. The click is recorded only after every guard has passed, which means 404s, expired links, and capped links never pollute the analytics. And it is recorded before redirect(), because in Next.js redirect() works by throwing — nothing after that line runs.

Why a temporary redirect, not a 301

The redirect() function in a server component issues a temporary redirect (a 307), and for a shortener that is exactly what you want. Browsers cache permanent redirects aggressively: serve a 301 or 308 once and that visitor may never hit your server again — no click counted, and no way to change the destination for them. A temporary status keeps every click flowing through your route. The full decision tree between 301, 302, 307, and 308 is its own topic — see 301 vs 302 Redirects (and 307, 308) if you want the semantics in depth.

Forwarding query strings and UTM parameters

This is the part most homemade shorteners get wrong. Put a short link in a Google Ads campaign and Google appends a gclid to it; put UTM parameters on the short URL and you expect them to reach the destination. If your redirect throws the query string away, your attribution silently dies. The buildDestinationUrl helper handles three cases:

export function buildDestinationUrl(
  destinationUrl: string,
  incomingSearch: string,
  forwardQuery: boolean,
  appendUtm: Record<string, string> | null | undefined,
): string {
  const target = new URL(destinationUrl);
  const incoming = new URLSearchParams(incomingSearch);

  if (forwardQuery) {
    for (const [k, v] of incoming) {
      if (!target.searchParams.has(k))
        target.searchParams.set(k, v);
    }
  } else {
    // Always preserve ad click IDs so conversions can
    // still be attributed on the destination.
    for (const key of ["gclid", "wbraid", "gbraid"]) {
      const v = incoming.get(key);
      if (v && !target.searchParams.has(key))
        target.searchParams.set(key, v);
    }
  }

  if (appendUtm) {
    for (const [key, value] of Object.entries(appendUtm)) {
      if (!value) continue;
      const utmKey =
        key.startsWith("utm_") ? key : "utm_" + key;
      if (!target.searchParams.has(utmKey))
        target.searchParams.set(utmKey, value);
    }
  }

  return target.toString();
}

When forward_query is on, incoming parameters merge into the destination without ever overwriting parameters already baked into the destination URL. When it is off, the ad click IDs — gclid, wbraid, gbraid — pass through anyway, because dropping those breaks Google Ads conversion attribution entirely (I covered how that identifier actually works in What Is GCLID?). Finally, append_utm lets a link carry default campaign tags in a jsonb column, so the short URL you share stays clean while the destination still gets tagged — see my practical guide to UTM parameters for how to pick those values.

Recording clicks without slowing the redirect

The click recorder turns one request into one analytics row. It parses the user agent with ua-parser-js for device, browser, and OS, flags likely bots with a regex, reads geolocation from the headers Vercel adds to every request, and copies the attribution parameters out of the query string:

export async function recordClick(args: {
  link: { id: string; slug: string };
  headers: Headers;
  searchParams: URLSearchParams;
}) {
  const { link, headers, searchParams } = args;
  const ua = headers.get("user-agent");
  const ip = extractClientIp(headers);
  const { device, browser, os, isBot } = parseUa(ua);

  const admin = createSupabaseAdminClient();
  await admin.from("clicks").insert({
    link_id: link.id,
    slug: link.slug,
    ip_hash: hashIp(ip, env.IP_HASH_SECRET),
    country: headers.get("x-vercel-ip-country"),
    city: headers.get("x-vercel-ip-city"),
    device_type: device,
    browser,
    os,
    referrer: headers.get("referer"),
    gclid: searchParams.get("gclid"),
    utm_source: searchParams.get("utm_source"),
    utm_medium: searchParams.get("utm_medium"),
    utm_campaign: searchParams.get("utm_campaign"),
    is_bot: isBot,
  });
}

Three details earn their keep. First, bot traffic is flagged, not dropped — link previews from Slack or WhatsApp hit your shortener constantly, and keeping the rows with an is_bot column lets you filter either way at query time. Second, the x-vercel-ip-country family of headers gives you free coarse geolocation with no GeoIP database to maintain. Third, the insert is awaited before the redirect rather than fired and forgotten. On serverless platforms the runtime can be suspended as soon as the response is sent, so an un-awaited insert may simply never happen. One awaited insert adds a few milliseconds; losing analytics silently costs more.

Deploying on Vercel

Deployment is the least interesting part, which is the point of this stack. Apply the migration with supabase db push or by pasting it into the SQL editor in the Supabase dashboard, connect the repo to Vercel, and set three environment variables: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, and an IP_HASH_SECRET of your choosing. The service-role key bypasses RLS, so it must only ever live in server-side env vars — never give it a NEXT_PUBLIC_ prefix and never let it reach the client bundle. Then attach your short domain to the project.

Because the route is force-dynamic, every redirect is a server invocation rather than a cached response. At personal scale that is negligible. One honest caveat from running this in production: the Supabase free tier pauses databases after a stretch of inactivity, so the first click after a quiet period can be slow or fail until the database wakes. If your links matter, the smallest paid tier removes that failure mode.

The reserved-slug machinery is also what lets the shortener share a domain with everything else. This site serves its blog, its homepage, and the shortener from one Next.js app — short links at the root, everything else on named routes, and the reserved list keeping them from colliding.

Bottom line

A personal URL shortener is two Postgres tables, a catch-all [slug] route with force-dynamic, and three helpers: validate the slug, build the destination URL with query forwarding, record the click. Look links up with the service-role client, guard with is_active, expires_at, and click_limit, record the click only after the guards pass, and redirect with a temporary status so every click keeps reaching your server. Supabase gives you the database, auth, and RLS; Vercel gives you the deploy and the geo headers. The result is a shortener you fully own — links you can edit forever and click data you can query with plain SQL.