Back to all posts

Building Zort: A Minimal Link Shortener with Astro, Supabase, and Vercel

5 min read
#full-stack #astro #supabase
Building Zort: A Minimal Link Shortener with Astro, Supabase, and Vercel

The Stack

  • Astro 6 — mostly for the island architecture and the fact that static pages are actually static
  • Supabase — Postgres with row-level security, auth, and a clean JS client
  • Tailwind CSS v4 — via the new Vite plugin, no PostCSS config needed
  • Vercel — zero-config deploys with the official Astro adapter

Nothing exotic. The interesting part was threading them together correctly.


How a Redirect Actually Works

The core of any link shortener is deceptively simple: receive a slug, look up a URL, redirect. Zort's [slug].astro page does exactly that — but with a couple of additions.

User visits /abc123
  → Supabase lookup: slug = "abc123"
  → Log analytics row (link_id, referrer)
  → Increment click counter (RPC)
  → 302 redirect to destination

The analytics write and the counter increment both happen via Promise.allSettled, so a failing telemetry call never blocks the redirect. The user gets to where they're going; I still (usually) get the data.

One subtle thing: the slug page uses export const prerender = false to opt out of static generation, while the homepage uses export const prerender = true. Astro's hybrid output mode lets you mix these freely, which keeps the marketing shell as a fast static page while the redirect logic stays fully server-rendered.


Two Supabase Clients, One Good Reason

There are two separate Supabase client files in the project:

  • supabase.ts — initialized with the anon key, runs in the browser, respects row-level security
  • supabase-server.ts — initialized with the service role key, runs only on the server, bypasses RLS

This matters because the redirect lookup needs to work for any slug regardless of who's asking. If I used the anon client there, I'd need an RLS policy that allows public reads — which is fine, but opens the door to mistakes. Using the service role key server-side and never shipping it to the browser is cleaner: the secret stays secret, and the browser client only ever touches data the authenticated user is allowed to see.


Auth Without a Backend Route

Supabase's JS client handles the whole auth flow in the browser — sign up, sign in, session persistence, token refresh. The login page is a static Astro file with a vanilla <script> block that calls supabase.auth.signInWithPassword() and supabase.auth.signUp().

One quality-of-life detail I'm happy with: after signup, instead of just saying "check your email," the UI detects the user's email provider and surfaces quick links directly to Gmail, Outlook, Proton, or Yahoo — whichever matches their domain. Small thing, but it removes a friction point.


The Anonymous Link Toggle

Logged-in users see a checkbox: Anonymous link. When checked, the link is inserted with user_id = null, so it never appears in their dashboard and isn't associated with their account. Useful for sharing something without it living in your history permanently.

The logic is two lines:

const anonymous = anonCheck.checked;
const user_id = (currentUser && !anonymous) ? currentUser.id : null;

Supabase RLS on the links table then ensures users can only read and delete rows where user_id = auth.uid(). Anonymous links are safe from deletion by anyone (including their creator, once the session ends).


Dark Mode Without a Flash

Getting dark mode right without a flash of the wrong theme is a classic annoyance. The fix is an inline script in the <head> — before any CSS loads — that reads localStorage and adds the dark class to <html> synchronously:

(function() {
  const saved = localStorage.getItem('theme');
  if (saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
    document.documentElement.classList.add('dark');
  }
})();

Tailwind v4's @custom-variant dark directive then handles the rest via class-based switching rather than the default media query. A toggle button in the corner persists the preference.


What I'd Add Next

  • Custom domains — right now everything runs under one domain. It'd be interesting to let users map their own.
  • Link expiry — a expires_at column and a check in the redirect handler would be straightforward.
  • QR codes — generate one per link, rendered client-side with a canvas library.
  • Better analytics — the current schema captures referrer and timestamp. Adding country (from Vercel's request headers) and a simple chart on the dashboard would make it genuinely useful.

Takeaways

Astro's hybrid rendering model is underrated for projects like this. You get the speed of a static site for everything that doesn't need to be dynamic, and full server rendering exactly where you need it — without switching frameworks or running a separate API server.

Supabase continues to be the fastest path from "I have a Postgres idea" to "it's running in production." The RLS model takes some getting used to, but once it clicks, it's genuinely elegant.

Zort is small, but it does exactly what I wanted: I own the redirects, I see the clicks, and it runs for essentially free.


Source on GitHub · Live at zort.rf.gd