← All work

Building Tracker, a Minimal Self-Hosted Strava

A deliberately lazy fitness log: server actions, SQLite, and zero client-side fetching

Case StudyNext.jsSQLiteOpen SourceFrisson
3 min read

Tracker is a personal fitness log I built as a minimal, self-hosted Strava replacement. You log activities manually or import Garmin .fit files, track mileage on your shoes and watches, and look at simple stats. It is multi-user but has no public signup; accounts are created from the CLI, because it is built for a handful of people, not the internet. It is open source under MIT at github.com/frisson-supply/tracker.


The interesting part is not the feature list. It is how much I refused to add.

No API routes, no client fetch

Every page is a React Server Component and every mutation is a server action. Forms are plain <form action={...}> with native inputs. There is no /api directory, no client-side data fetching, no state management library. Next.js already ships the whole request cycle; I just used it.

No migration system

The database is Turso (libSQL, so SQLite). The schema is created with CREATE TABLE IF NOT EXISTS on first query, and constraints live in the database rather than in app code:

sql
CREATE TABLE IF NOT EXISTS activities (
  -- ...
  sport TEXT NOT NULL CHECK (sport IN ('run', 'ride', 'walk', 'swim', 'other'))
)

Every row is scoped to a user_id and every query filters on it. For local development you can point the client at a file: database and work fully offline.

Stateless auth in the edge proxy

Sessions are a cookie shaped like <userId>.<hmac(userId, SESSION_SECRET)>, signed with Web Crypto so verification runs in the edge proxy before any page renders. There is no session table. Rotating the secret logs everyone out at once, which doubles as the emergency brake. Passwords are scrypt-hashed in a Node-only module that never reaches the edge or the client, and login is rate-limited: five failures buys you a one-minute lockout.

No map library

FIT imports come with GPS tracks, and the obvious move is to pull in Leaflet or Mapbox. Instead, routes are downsampled to at most 500 points, stored as JSON, and rendered as a plain SVG polyline. Altitude, heart rate, pace, and cadence get the same treatment: a small hand-rolled sparkline component with hover and touch scrubbing, capped at 200 points per series. Pace is always derived from distance and time, never stored.


Parsing FIT files hides some fun domain quirks: coordinates arrive in semicircles, the parser's lengthUnit: 'km' option also scales altitude (so you have to multiply it back), and Garmin reports run and walk cadence per leg, so the real value is doubled.

Gear as one table

Shoes and watches share a single table, discriminated by a kind column. Shoes attach to runs and walks, watches to any sport, and both are reassignable from the activity page. Two nearly identical tables would have been the "clean" answer; one table was the correct amount of code.


Your data stays yours: a JSON export lives at /export, and the original FIT files are kept and downloadable. The whole thing runs on Vercel with CSS Modules, a grayscale palette, a system font stack, and zero UI libraries. If you want a fitness log you actually own, clone it and add yourself with pnpm add-user.