Lightweight, open-source race timing for grassroots mountain bike events. Two volunteers, two phones, zero special equipment.
OpenRace lets race organizers time enduro, downhill, and cross-country mountain bike races using nothing but smartphones. Volunteers open a link on their phone, tap a button when each rider starts or finishes, and results appear automatically.
Key features:
- No equipment needed — just phones with a browser
- No volunteer accounts — share a QR code or link, they start timing immediately
- Offline-first — times are captured locally and sync when connectivity returns
- Instant results — public results page with category filtering and stage splits
- Multi-stage support — enduro races with any number of stages
- CSV rider import — paste a spreadsheet of riders and go
| Type | Description |
|---|---|
| Enduro | Multiple timed stages, lowest combined time wins |
| Downhill (DH) | Single timed stage, fastest run wins |
| Cross-Country (XC) | Single stage, mass start, first to finish wins |
- Organizer creates a race, adds stages and riders
- Organizer shares QR-coded timer links with volunteers (one for start, one for finish per stage)
- Volunteers open the link on their phone — no login required
- Volunteers tap the big button each time a rider starts or finishes
- Times sync to the server and results update automatically
- Spectators view live results via a public share link
- Next.js 16 (App Router, React 19, TypeScript)
- Supabase (Postgres, Auth, Row Level Security)
- Tailwind CSS 4
- IndexedDB via idb (offline storage)
- Zod 4 (validation)
- Vitest (testing)
- PWA-ready (installable on mobile)
git clone https://github.com/JeffBrines/OpenRace.git
cd OpenRace
pnpm installCreate a Supabase project (or use an existing one). OpenRace uses a dedicated openrace schema so it won't conflict with other tables.
Apply the database migrations in order:
# Using the Supabase CLI
supabase db pushOr apply the SQL files manually from supabase/migrations/ (001 through 007) via the Supabase SQL Editor.
Expose the schema: In your Supabase Dashboard, go to Project Settings > API > Exposed schemas and add openrace to the list.
cp .env.example .env.localFill in your Supabase credentials:
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-keypnpm devOpen http://localhost:3000.
src/
├── app/
│ ├── page.tsx # Landing page
│ ├── (auth)/ # Login & signup
│ ├── dashboard/ # Organizer dashboard (auth-protected)
│ │ ├── page.tsx # Race list
│ │ ├── create/ # Race setup wizard (4 steps)
│ │ └── [raceId]/ # Race day dashboard
│ ├── time/[token]/ # Volunteer timing screen (no auth)
│ └── r/[shareCode]/ # Public results
│ └── [riderId]/ # Individual rider result
├── components/
│ ├── auth/ # Auth form
│ ├── race/ # Race cards, wizard steps, dashboard
│ ├── timing/ # Timing screen UI
│ └── dashboard/ # Sign out button
├── hooks/
│ ├── use-timing.ts # Timing state & actions
│ └── use-connection.ts # Online/offline monitor
└── lib/
├── db/ # Supabase database operations
├── offline/ # IndexedDB schema, operations, sync queue
├── timing/ # Timing capture logic
├── supabase/ # Supabase client setup
├── validators/ # Zod schemas
└── utils/ # Device ID, tokens, time formatting, CSV import
OpenRace uses a dedicated openrace Postgres schema with Row Level Security:
- races — race metadata, owned by organizer
- stages — timed stages within a race, with unique start/finish tokens
- riders — participants with name, bib, category, gender
- time_records — timestamped start/finish events, linked to stage and optionally to rider
- stage_results (view) — computed elapsed times per rider per stage
- race_results (view) — aggregated results with overall and category rankings
| Role | Access |
|---|---|
| Organizer (authenticated) | Full CRUD on own races, stages, riders, time records |
| Volunteer (anonymous) | INSERT time records for active races only |
| Public (anonymous) | READ races, stages, riders, results for active/complete races |
No service role key is needed — everything works through the anon key with RLS.
# Run all tests
pnpm test
# Watch mode
pnpm test:watchTests cover: time formatting, token generation, CSV import, IndexedDB operations, sync queue, and timing capture logic.
The volunteer timing screen works without internet:
- Times are captured instantly to IndexedDB on the device
- A sync queue flushes pending records to Supabase every 5 seconds when online
- The connection monitor shows online/offline status in the UI
- UUID-based deduplication prevents duplicate records on retry
Volunteers never lose a time — even if they're in a cell dead zone on the side of a mountain.
Contributions are welcome! This project is built for the grassroots racing community.
- Fork the repo
- Create a feature branch (
git checkout -b feat/my-feature) - Make your changes with tests
- Submit a pull request
MIT
Built for the mountain bike community by riders who got tired of expensive timing systems and terrible UX.