A reliable, self-hostable URL shortener with click analytics. A single Next.js app (App Router, TypeScript) talks to Postgres via Drizzle ORM.
- Create short links from any
http/httpsURL (with validation + SSRF guard). - Redirect
GET /:code→ original URL (302), logging each click without blocking the redirect. - Analytics per link: total click count + recent click events.
- Health endpoint for uptime checks.
Built with Next.js 16, React 19, Drizzle ORM (Postgres), Zod, Vitest, and Playwright.
- Node.js 20+
- A Postgres database. This project is configured for a cloud Postgres (e.g. Supabase); any Postgres 14+ works.
# 1. Install dependencies
npm install
# 2. Configure environment
cp .env.example .env
# - set DATABASE_URL to your Postgres connection string
# - set IP_HASH_SALT to a long random string- Percent-encode special characters in the password. For example a literal
@must be written as%40, otherwise URL parsers mistake it for the user/host separator. - For Supabase (and other providers using a self-signed root chain), append
?sslmode=no-verifyso thepgdriver connects over TLS without rejecting the chain against Node's default CA bundle.
Example:
DATABASE_URL=postgresql://postgres:p%40ssword@db.<ref>.supabase.co:5432/postgres?sslmode=no-verify
npm run db:migrate # creates the `links` and `clicks` tablesOther DB scripts: npm run db:generate (generate a migration after editing
src/db/schema.ts), npm run db:studio (open Drizzle Studio).
npm run dev # http://localhost:3000For production:
npm run build
npm run startnpm test # unit + integration (Vitest) — needs DATABASE_URL reachable
npm run test:e2e # Playwright end-to-end (builds + starts the app)Integration tests
TRUNCATEthelinks/clickstables between cases and run serially (a single shared database), so pointDATABASE_URLat a database you are comfortable wiping.
The app lives at the repo root, so Vercel auto-detects Next.js.
-
Import this repo in Vercel (or run
vercel linkfrom the project directory). -
Set environment variables for the Production environment (Project Settings → Environment Variables):
Variable Value DATABASE_URLSupabase transaction pooler string (see below) IP_HASH_SALTa long random string NEXT_PUBLIC_BASE_URLyour production URL, e.g. https://your-app.vercel.app -
Deploy:
vercel deploy --prod(or push to the connected Git branch).
Vercel's serverless runtime cannot reach Supabase's direct connection
(db.<ref>.supabase.co:5432) — it is IPv6-only and Vercel functions can't route
to it, so every query fails and GET /api/health reports db: "down". Use the
Transaction pooler instead (Supabase dashboard → Connect →
Transaction pooler). It is IPv4 and built for serverless:
postgresql://postgres.<ref>:<password>@aws-1-<region>.pooler.supabase.com:6543/postgres?sslmode=no-verify
- The host prefix may be
aws-1-(newer projects) oraws-0-— copy the exact host shown in your dashboard. - Percent-encode special characters in the password (
@→%40). ?sslmode=no-verifyletspgconnect over TLS without rejecting Supabase's self-signed certificate chain.
For local development the direct connection (:5432) works fine; the pooler
is only required on serverless.
Base URL defaults to http://localhost:3000 (NEXT_PUBLIC_BASE_URL).
Create a short link.
curl -X POST http://localhost:3000/api/links \
-H 'content-type: application/json' \
-d '{"url":"https://example.com/some/very/long/path"}'201:
{
"shortCode": "aZ09bYx",
"shortUrl": "http://localhost:3000/aZ09bYx",
"originalUrl": "https://example.com/some/very/long/path"
}Errors return a JSON envelope { "error": { "code", "message" } }:
400 invalid_url, 400 invalid_body, 429 rate_limited (with Retry-After),
500 server_error.
Redirects (302) to the original URL and records a click. Returns 404 for an
unknown or malformed code.
curl -i http://localhost:3000/aZ09bYxcurl http://localhost:3000/api/links/aZ09bYx/stats200:
{
"shortCode": "aZ09bYx",
"originalUrl": "https://example.com/some/very/long/path",
"clickCount": 3,
"createdAt": "2026-06-03T12:00:00.000Z",
"recent": [
{ "clickedAt": "...", "referrer": "...", "userAgent": "..." }
]
}404 not_found for an unknown code.
curl http://localhost:3000/api/health
# {"status":"ok","db":"up"} (503 with db:"down" when unreachable)src/
app/
page.tsx # home: create form + recent links
ShortenForm.tsx # client form component
[code]/route.ts # GET /:code redirect resolver
api/
links/route.ts # POST /api/links
links/[code]/stats/route.ts # GET stats
health/route.ts # GET health
db/
schema.ts # Drizzle tables (links, clicks)
client.ts # pooled Drizzle client
queries.ts # data-access functions
lib/
codes.ts # base62 short-code generation
validate-url.ts # URL validation + SSRF guard
rate-limit.ts # in-memory token-bucket limiter
request.ts # client IP + IP hashing
http.ts # JSON error envelope
tests/db-setup.ts # integration reset helper
e2e/shorten.spec.ts # Playwright happy path
drizzle/ # generated migrations
- Visitor IPs are hashed (SHA-256 + salt) before storage; raw IPs are never persisted.
- Link creation is rate-limited per IP (
RATE_LIMIT_MAX/RATE_LIMIT_WINDOW_MS). - Submitted URLs are validated and blocked from pointing at loopback / private / link-local addresses (SSRF guard).