A link shortener with click analytics: paste a URL, get a short one back, and (optionally) sign in to claim it, pick a custom code, and see who's clicking it.
This is the fourth in a series of self-contained portfolio repos — BriefGenerator (Flask, OAuth2 API), OpsDesk (Next.js full-stack app), and WebhookRelay (Go, distributed webhook delivery) are the other three.
Unlike those, this one isn't modeled on a pattern from my day job — it's built specifically to give React its own showcase. BriefGenerator is API-only, WebhookRelay is API-only Go, and OpsDesk's React is filtered through Next.js's server-component/server-action conventions. LinkPulse is the one where the frontend is the point: a plain Vite + React + TypeScript SPA talking to a separate API, so the component architecture, hooks, and client-side data flow aren't sharing the spotlight with a meta-framework.
The backend is also deliberately different from the other two Python/Node repos: FastAPI instead of Flask, Postgres instead of SQLite, and a public, mostly-anonymous-by-default access model instead of an OAuth2 machine client or a session-gated internal tool.
- Frontend: React + Vite + TypeScript, Tailwind CSS, TanStack Query, React Router, Recharts
- Backend: FastAPI, SQLAlchemy + Alembic, PostgreSQL, Pydantic, JWT auth
(
python-jose+passlib/bcrypt),slowapifor rate limiting - Testing: pytest (backend)
Browser (React SPA, Vite dev server / static build)
|
| fetch() with an optional Bearer JWT
v
FastAPI app (app/main.py)
|
+-- /api/auth/* -> register, login, current-user (app/routers/auth.py)
+-- /api/links -> create/list/delete links (app/routers/links.py)
+-- /api/links/{code}/analytics -> per-link breakdowns (app/routers/analytics.py)
+-- /{code} -> 302 redirect + click logging (app/routers/redirect.py)
|
v
SQLAlchemy models (User, Link, Click) -> PostgreSQL
- Auth:
app/security.pyis pure crypto — bcrypt hashing viapasslib, JWT sign/verify viapython-jose— with no FastAPI dependency, so it's directly unit-tested.app/deps.pywraps it asget_current_user(401 if missing/invalid) andget_current_user_optional(used by link creation, since that endpoint works both signed-out and signed-in). - Anonymous vs. claimed links:
POST /api/linksworks with no auth at all — it just rate-limits and validates the URL. If a valid JWT is attached, the link is associated with that user and can use a custom vanity code; anonymous requests only get an auto-generated code. That split lives inapp/routers/links.pyandapp/crud.py. - URL validation:
app/utils/url_validation.pyrejects non-http(s) schemes (javascript:,data:,file:, …), malformed input, and loopback/private/link-local hosts — the last one specifically to stop this service from being used as an SSRF pivot into a private network via its own redirect endpoint. - Click logging: every hit to
GET /{code}writes aClickrow — timestamp, referrer header, and a parsed browser/OS/device type via theuser-agentslibrary — before issuing the 302. No raw IP is stored; the analytics dashboard only needs the parsed fields. - Rate limiting:
slowapilimits registration, login, and link creation per client IP (see the decorators inapp/routers/*.py), so the no-login shorten flow can't be trivially hammered. - Route ordering matters:
GET /{code}is a catch-all path parameter, soapp/main.pyregisters it dead last — otherwise it would shadow/health,/docs, and every/api/*route added before it. There's a comment on that include call explaining why. - Frontend composition:
src/api/*.tsare plain fetch functions with no React in them;src/hooks/useLinks.tswraps them in TanStack Query for caching/invalidation;src/context/AuthContext.tsxis the one piece of global state (the current user), read via auseAuth()hook everywhere else. Pages compose presentational components (ShortenForm,LinkTable, the Recharts wrappers undercomponents/analytics/) rather than owning markup directly.
git clone <this-repo>
cd linkpulse
docker compose up --buildThis starts Postgres and the FastAPI app (running alembic upgrade head
on boot) at http://localhost:8000. Try it:
curl -X POST http://localhost:8000/api/links \
-H "Content-Type: application/json" \
-d '{"original_url": "https://example.com"}'cd frontend
cp .env.example .env
npm install
npm run devVisit http://localhost:5173. The dev server talks to the API at
VITE_API_BASE_URL (defaults to http://localhost:8000).
cd backend
python -m venv .venv
source .venv/bin/activate # .venv\Scripts\activate on Windows
pip install -r requirements.txt
cp .env.example .env
# point DATABASE_URL at your own Postgres instance
alembic upgrade head
uvicorn app.main:app --reloadcd backend
pytestThe suite runs against an in-memory SQLite database (see
tests/conftest.py), so it needs no running Postgres. Coverage:
- URL validation: scheme/host rejection, SSRF-style private/loopback
addresses, length limits (
tests/test_url_validation.py) - Password hashing and JWT sign/verify, including tampered and expired
tokens (
tests/test_security.py) - Auth endpoints: register, login, duplicate-email handling,
/me(tests/test_auth_api.py) - Link creation (anonymous and authenticated), custom-code rules and
collisions, ownership-scoped listing and deletion (
tests/test_links_api.py) - The redirect endpoint: 302 behavior, 404 on unknown codes, and that a
click row (with parsed user-agent fields) is actually written
(
tests/test_redirect.py) - Analytics aggregation: totals, clicks-over-time, and referrer/browser/device
breakdowns, including the zero-clicks empty case (
tests/test_analytics.py)
There's no frontend test suite yet — see Roadmap.
- No frontend automated tests (component or e2e).
- The redirect endpoint's rate limit is a simple in-memory, per-process
bucket (
slowapi's default), so it doesn't coordinate across multiple API instances — fine for a single-container demo, not for a real horizontally-scaled deployment. - The initial Alembic migration was written by hand against the SQLAlchemy
models rather than autogenerated against a live Postgres instance (no
local Postgres in the environment this was built in). It's a
straightforward three-table schema; double-check it against
alembic upgrade headbefore relying on it for anything beyond this demo.
- Component tests for the React app (React Testing Library) and a Playwright smoke test for the shorten -> claim -> view analytics flow
- QR code generation per link
- Link expiration / deactivation
- A Redis- or Postgres-backed rate limit store for multi-instance
deployments, replacing
slowapi's in-memory default - Bulk link import/export
MIT. See LICENSE.