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 securitysupabase-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_atcolumn 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
Connect with me