ps-shin
Back to projects
2026·Active

shortlink

URL shortener with click tracking. Go library plus a TypeScript port that powers the live /r/* links on this site.

GoTypeScriptPostgresNeonVercel

Try it live

Paste a URL and get a real shortlink served by the deployed Go service.

How it works

Shortlink — create + redirect

Why this exists

Every side project I want to share eventually has long, ugly URLs. Hosted shorteners are easy but they own your data and disappear when they pivot. shortlink is the smallest thing I'd actually run myself — one table, one binary or one route handler, deployed in two commands.

It also powers the /r/* redirects on this portfolio.

Two implementations, on purpose

The repo at PS-safe/shortlink is the canonical reference — a Go library plus a runnable HTTP server, with memory and postgres backends behind a single Store interface:

import (
    sl "github.com/PS-safe/shortlink"
    "github.com/PS-safe/shortlink/memory"
)

store := memory.New()
link, _ := store.Create(ctx, "abc", "https://example.com")
target, _ := store.Resolve(ctx, "abc") // atomic UPDATE … RETURNING target

What's deployed on this site, though, is a TypeScript port living inside the Next.js app (lib/shortlink.ts + /app/api/shorten + /app/r/[slug]). It talks straight to Neon Postgres from a Vercel route handler.

Why two implementations isn't accidental

The Go service is the artifact I want to carry between employers — a real library someone can import, deploy, and operate. That's the research-mode goal.

The TS port is the right choice for this specific deployment target. Running a second service (on Fly, Render, Koyeb, etc.) just to serve a portfolio demo would mean: another platform account, cross-service auth, cold starts on two systems, and one more thing to monitor. Picking a smaller architecture when the constraints allow it is a senior judgment call, not a downgrade.

Both implementations share the same Postgres schema and the same data model. If I ever need the Go service to take over (more traffic, multi-tenant, separate scaling), the database is already speaking its language.

Design decisions worth noting (apply to both ports)

  • Click increment is part of the redirect query. UPDATE links SET click_count = click_count + 1 … RETURNING target — atomic, no race, no second round trip.
  • Slugs come from a CSPRNG with rejection sampling, not modulo-skewed random bytes. Uniform and unpredictable by default — Go's crypto/rand on the server, crypto.getRandomValues in the TS port.
  • Contract tests, not per-backend tests. A single RunContract function in Go exercises every method; new backends just call it. The TS port shares the same data model so it's covered indirectly.
  • Strict URL validation at the boundary. Only http/https with a real host. Internal hostnames (localhost, 127.0.0.1) rejected to avoid open-redirect / SSRF surprises.

Future work

  • [ ] Custom slug support exposed through the public form
  • [ ] Expiry / TTL per link
  • [ ] Per-click metadata (referrer, UA, country) into a separate clicks table
  • [ ] Simple admin view for recent links + click counts