A complete web application that automatically aggregates race results from Strava and parkrun for running clubs. Built for clubs with 200+ members, fully open source, and deployable in under an hour.
This guide is specifically written to help clubs replicate this system for their own use.
- Is This Right For Your Club?
- Quick Replication Checklist
- What You'll Get
- Costs
- Prerequisites
- Complete Setup Guide
- Customizing For Your Club
- Running Your Club System
- parkrun Integration
- Troubleshooting
- Technical Reference
- Contributing
- Has members who use Strava to track their runs
- Wants to automatically collect race results from members
- Would like to track parkrun participation
- Has at least one person comfortable with following technical setup instructions
- Wants a free, self-hosted solution (no monthly fees)
- Members connect via Strava OAuth (one-click authorization)
- Automatically syncs all activities marked as "Race" in Strava
- Weekly automated sync keeps results up to date
- Optional parkrun result tracking (admin-managed)
- Public dashboard shows all club race results
- Admin panel for managing users and data
- One person to do initial setup (1-2 hours)
- One admin to occasionally manage users and trigger syncs
- Members must mark races as "Race" workout type in Strava
Here's everything you need to set up this system for your club:
- GitHub account (to fork and deploy the code)
- Cloudflare account (to host the application)
- Strava API application (to access member data)
- Google Cloud project (optional, for admin authentication)
- Your club's Strava Club ID (found in club URL)
- Your club name and branding colors
- Your parkrun club number (if using parkrun integration)
- Email for the first admin user
| Task | Time |
|---|---|
| Creating accounts and API apps | 15-20 minutes |
| Forking and configuring code | 15-20 minutes |
| Deploying to Cloudflare | 10-15 minutes |
| Testing and first sync | 10-15 minutes |
| Total | ~1 hour |
Automatic Race Tracking
- Connect with Strava once, then forget about it
- All races automatically appear in the club dashboard
- View personal stats alongside clubmates
- Filter by date, distance, event, or athlete
parkrun Results (if enabled)
- Separate parkrun dashboard
- Historical parkrun data
- Weekly summaries
Admin Dashboard
- View all connected members
- See sync status and race counts
- Trigger manual syncs when needed
- Manage user visibility and permissions
Data Management
- Edit race names and distances
- Classify events with AI assistance
- Import parkrun results
- GDPR-compliant data deletion
The application includes:
- Home page - Landing page explaining the service
- Race Dashboard - Sortable, filterable table of all club races
- parkrun Dashboard - Dedicated parkrun results view
- Admin Panel - Multi-tab interface for management
- Sync Monitor - Real-time view of sync progress
| Resource | Free Tier Limit | Typical Club Usage |
|---|---|---|
| Worker Requests | 100,000/day | ~1,000/day |
| D1 Database Reads | 5M/day | ~10,000/day |
| D1 Database Writes | 100,000/day | ~1,000/day |
| D1 Storage | 5GB | ~100MB |
| Pages Bandwidth | Unlimited | ~1GB/month |
Bottom line: A club with 200+ members fits comfortably in the free tier.
- Very large clubs (500+) during initial sync of historical data
- Very frequent manual syncs (more than several times daily)
- Custom domain with Cloudflare (optional, ~$10/year for domain)
- Domain name (optional): ~$10-15/year
- GitHub (always free for public repos)
- Strava API (free)
Before starting, ensure you have:
- Node.js 18+ installed (Download)
- Git installed (Download)
- Basic comfort with command line/terminal
- A code editor (VS Code recommended)
- Go to dash.cloudflare.com/sign-up
- Sign up with email
- Verify your email
- Go to strava.com/settings/api
- Click "Create Application" (you may need to agree to terms)
- Fill in the form:
- Application Name:
[Your Club] Race Results - Category:
Visualizer - Website:
http://localhost:3000(will update later) - Application Description:
Aggregates race results for [Club Name] - Authorization Callback Domain:
localhost
- Application Name:
- Save your Client ID and Client Secret somewhere safe
If you want admins to log in with Google instead of Strava:
- Go to console.cloud.google.com
- Create a new project
- Go to "APIs & Services" > "Credentials"
- Create OAuth 2.0 Client ID
- Set authorized redirect URI to your production URL +
/auth/google/callback
# Fork on GitHub first, then clone your fork
git clone https://github.com/YOUR_USERNAME/strava-club-results.git
cd strava-club-results
# Install all dependencies
npm installnpm install -g wrangler
# Log in to Cloudflare
wrangler loginThis opens a browser window - authorize the CLI to access your Cloudflare account.
# Create a new D1 database
wrangler d1 create strava-club-dbImportant: Copy the database_id from the output. It looks like: 4b79f9e5-f6bb-4cd9-852b-51e2c0a7c5d1
Edit workers/wrangler.workers.toml:
name = "your-club-workers" # Change to your club name
[[d1_databases]]
binding = "DB"
database_name = "strava-club-db"
database_id = "YOUR_DATABASE_ID_HERE" # Paste your ID hereIn the same file, update the [vars] section:
[vars]
STRAVA_REDIRECT_URI = "http://localhost:3000/auth/callback" # For local dev
STRAVA_CLUB_ID = "YOUR_CLUB_ID" # Find this in your club's Strava URLCreate workers/.dev.vars:
STRAVA_CLIENT_ID=your_strava_client_id
STRAVA_CLIENT_SECRET=your_strava_client_secret
# Add these if using Google OAuth
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secretcd workers
npm run migrate
cd ..This creates all the necessary tables and indexes.
Open two terminal windows:
Terminal 1 - Start the backend:
cd workers
npm run devTerminal 2 - Start the frontend:
cd frontend
npm run devVisit http://localhost:3000 and test:
- Click "Connect with Strava"
- Authorize the application
- You should be redirected back and see your profile
cd workers
# Set each secret (you'll be prompted to enter values)
wrangler secret put STRAVA_CLIENT_ID
wrangler secret put STRAVA_CLIENT_SECRET
# If using Google OAuth
wrangler secret put GOOGLE_CLIENT_ID
wrangler secret put GOOGLE_CLIENT_SECRET
# For parkrun import API
wrangler secret put PARKRUN_API_KEY
# Generate a random string for this, e.g.: openssl rand -hex 32Edit workers/wrangler.workers.toml:
[vars]
STRAVA_REDIRECT_URI = "https://your-club.pages.dev/auth/callback"
# Or with custom domain:
# STRAVA_REDIRECT_URI = "https://results.yourclub.com/auth/callback"Add these secrets to your GitHub repository (Settings > Secrets and variables > Actions):
CLOUDFLARE_API_TOKEN: Create at Cloudflare API Tokens- Use template "Edit Cloudflare Workers"
- Add permissions: Account.D1 (Edit), Account.Cloudflare Pages (Edit)
CLOUDFLARE_ACCOUNT_ID: Found in Cloudflare dashboard sidebar
Push to your main branch:
git add .
git commit -m "Configure for [Your Club Name]"
git push origin mainGitHub Actions will automatically:
- Run database migrations
- Deploy Workers to Cloudflare
- Build and deploy frontend to Cloudflare Pages
Go back to strava.com/settings/api and update:
- Website: Your production URL
- Authorization Callback Domain: Your production domain (without https://)
- Example:
your-club.pages.devorresults.yourclub.com
- Example:
- Visit your production site
- Click "Connect with Strava" and authorize
- Access your D1 database in Cloudflare dashboard:
- Go to Workers & Pages > D1
- Click your database
- Go to "Console" tab
- Run this SQL (replace with your Strava ID):
UPDATE athletes SET is_admin = 1 WHERE strava_id = YOUR_STRAVA_ID;To find your Strava ID: it's in the URL when you view your Strava profile.
- Visit
/admin- you should see the admin panel - Go to "Sync Queue" tab and trigger a sync for yourself
- Watch the sync progress in "Sync Logs" tab
- Check the main dashboard - your races should appear!
Edit frontend/src/components/Layout.tsx:
// Find and update:
<h1>Your Club Name Results</h1>Edit frontend/src/pages/Home.tsx to update the welcome text.
Edit frontend/src/index.css:
:root {
--primary-color: #667eea; /* Your primary brand color */
--secondary-color: #764ba2; /* Your accent color */
--background-color: #f5f5f5;
--text-color: #333;
}Edit frontend/index.html:
<title>Your Club Name - Race Results</title>
<meta name="description" content="Race results for Your Club Name">Edit workers/wrangler.workers.toml:
[triggers]
crons = ["0 2 * * 1"] # Default: Monday 2 AM UTCCommon schedules:
"0 2 * * 1"- Weekly on Monday at 2 AM"0 2 * * *"- Daily at 2 AM"0 2 * * 1,4"- Monday and Thursday at 2 AM
Edit frontend/src/pages/Home.tsx to:
- Add your club logo
- Update welcome message
- Add links to your club website
- Customize the "how to connect" instructions
The default distance filters in the dashboard can be customized in frontend/src/components/RaceFilters.tsx.
-
Check sync status (Monday morning)
- Visit
/admin> "Sync Queue" tab - Ensure the automatic sync ran successfully
- Check for any athletes stuck in "syncing" status
- Visit
-
Review event suggestions (as needed)
- Visit
/admin> "Events" tab - Approve or reject AI-suggested event names
- This helps with filtering and statistics
- Visit
When club members want to join:
- Direct them to your results site
- They click "Connect with Strava"
- They authorize the application
- Their data syncs automatically with the next scheduled sync
- Or trigger a manual sync for them via admin panel
When a member wants their data removed:
- They can delete their own data by visiting
/auth/disconnect - Or an admin can delete them from the admin panel
- This removes all their data (GDPR compliant)
Signs of problems:
- Multiple athletes stuck in "syncing" for more than 1 hour
- Errors in sync logs
- No new activities appearing
Solutions:
- Use "Reset Stuck Syncs" button in admin panel
- Check Cloudflare dashboard for Worker errors
- Review rate limit status (Strava allows 100 req/15min)
parkrun doesn't have a public API, so results are collected using a browser-based tool and imported by an admin.
- Go to parkrun.com
- Navigate to your club's results page
- The club number is in the URL:
clubNum=XXXX
Edit scripts/parkrun-automated.js:
const CLUB_NUMBER = 'YOUR_CLUB_NUMBER';
const API_ENDPOINT = 'https://your-workers-url/api/parkrun/import';The repository includes a GitHub Actions workflow for automated weekly collection.
Edit .github/workflows/parkrun-scraper.yml:
env:
PARKRUN_API_ENDPOINT: https://your-workers-url/api/parkrun/import
PARKRUN_API_KEY: ${{ secrets.PARKRUN_API_KEY }}Add PARKRUN_API_KEY to your GitHub repository secrets.
- Go to
https://www.parkrun.com/results/consolidatedclub/?clubNum=YOUR_CLUB_NUMBER - Open browser console (F12 or Cmd+Option+J)
- Paste the contents of
scripts/parkrun-smart-scraper.js - Press Enter and wait for collection to complete
- Results are automatically imported to your database
If using automation: Results are collected every Sunday at 12:00 UTC.
If collecting manually:
- Run the scraper on Sunday afternoon (after parkrun results are published)
- Check the parkrun dashboard to verify import
- Hide any athletes who shouldn't appear (privacy)
npm install -g wrangler- Check that
database_idinwrangler.workers.tomlmatches your D1 database - Run migrations:
cd workers && npm run migrate
- Verify redirect URI in
wrangler.workers.tomlmatches exactly what's in Strava API settings - Don't include trailing slash
- Ensure protocol matches (http for localhost, https for production)
- This appears during development
- Click "Advanced" then "Go to [app name] (unsafe)"
- For production, submit your app for Google verification
Check that in Strava:
- Activity type is "Run" (not Walk, Ride, etc.)
- Workout type is "Race" (edit activity > change workout type)
- Activity was created after the athlete connected
- Wait 10 minutes (large backlogs take time)
- Check sync logs for errors
- Use "Reset Stuck Syncs" in admin panel
- If persistent, manually update database:
UPDATE athletes SET sync_status = 'idle' WHERE sync_status = 'syncing';
Strava limits: 100 requests/15 minutes, 1000/day
Solutions:
- Reduce batch size in
workers/src/cron/sync.ts - Increase delay between batches
- Spread manual syncs throughout the day
cd frontend
npm install
npm run buildIf TypeScript errors appear, they must be fixed before deployment.
- Check the GitHub Issues
- Review Cloudflare Worker logs in dashboard
- Check browser console for frontend errors
- Review sync logs in admin panel
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
│ Frontend │────▶│ Cloudflare Pages │────▶│ Browser │
│ (React) │ │ (Static CDN) │ │ │
└─────────────┘ └──────────────────┘ └─────────────┘
│
▼
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
│ Workers │────▶│ Cloudflare │────▶│ Strava API │
│ (Backend) │ │ Workers │ │ │
└─────────────┘ └──────────────────┘ └─────────────┘
│
▼
┌──────────────────┐
│ Cloudflare D1 │
│ (SQLite DB) │
└──────────────────┘
| Component | Technology |
|---|---|
| Frontend | React 18, TypeScript, Vite |
| Backend | Cloudflare Workers, TypeScript |
| Database | Cloudflare D1 (SQLite) |
| Hosting | Cloudflare Pages + Workers |
| CI/CD | GitHub Actions |
| APIs | Strava API v3, Google OAuth 2.0 |
strava-club-results/
├── workers/ # Backend (Cloudflare Workers)
│ ├── src/
│ │ ├── api/ # API route handlers
│ │ │ ├── admin.ts # Admin endpoints
│ │ │ ├── races.ts # Race data endpoints
│ │ │ ├── parkrun.ts # parkrun endpoints
│ │ │ └── events.ts # Event classification
│ │ ├── auth/ # OAuth handlers
│ │ ├── cron/ # Scheduled sync jobs
│ │ ├── queue/ # Sync queue processing
│ │ ├── middleware/ # Auth middleware
│ │ ├── utils/ # Database, Strava client
│ │ └── index.ts # Main router
│ ├── wrangler.workers.toml # Cloudflare configuration
│ └── package.json
├── frontend/ # Frontend (React)
│ ├── src/
│ │ ├── components/ # Reusable UI components
│ │ ├── pages/ # Page components
│ │ │ ├── Dashboard.tsx # Race results view
│ │ │ ├── Admin.tsx # Admin panel
│ │ │ ├── Parkrun.tsx # parkrun results
│ │ │ └── SyncMonitor.tsx
│ │ ├── utils/ # API client
│ │ └── index.css # Global styles
│ └── package.json
├── database/
│ ├── schema.sql # Database schema
│ └── migrations/ # Migration files
├── scripts/ # Utility scripts
│ └── parkrun-automated.js # parkrun scraper
├── docs/ # Additional documentation
├── .github/workflows/ # CI/CD configuration
│ ├── deploy.yml # Main deployment
│ └── parkrun-scraper.yml # parkrun automation
└── README.md
athletes - Connected Strava users
strava_id, firstname, lastname, profile_photo
access_token, refresh_token, token_expiry
is_admin, is_hidden, is_blocked
sync_status, last_synced_atraces - Synced race activities
athlete_id, strava_activity_id
name, distance, elapsed_time, moving_time
date, elevation_gain
average_heartrate, max_heartrate
event_name, is_hiddenparkrun_results - Imported parkrun data
athlete_name, parkrun_athlete_id
event_name, event_number, date
position, time_seconds, age_gradesync_logs - Sync operation logs
athlete_id, sync_session_id
log_level, message, metadata
created_atSee database/schema.sql for complete schema with all tables and indexes.
| Method | Endpoint | Description |
|---|---|---|
| GET | /auth/authorize |
Start Strava OAuth |
| GET | /auth/callback |
OAuth callback |
| GET | /auth/me |
Get current user |
| DELETE | /auth/disconnect |
Delete all user data |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/races |
Get races with filters |
| GET | /api/stats |
Aggregate statistics |
| GET | /api/athletes |
List athletes |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/admin/athletes |
All athletes with details |
| POST | /api/admin/athletes/:id/sync |
Trigger sync |
| POST | /api/admin/reset-stuck-syncs |
Reset stuck syncs |
| PATCH | /api/admin/athletes/:id |
Update athlete |
| DELETE | /api/admin/athletes/:id |
Delete athlete |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/parkrun/results |
Get results |
| GET | /api/parkrun/stats |
Statistics |
| POST | /api/parkrun/import |
Import CSV |
STRAVA_CLIENT_ID=xxx
STRAVA_CLIENT_SECRET=xxx
GOOGLE_CLIENT_ID=xxx
GOOGLE_CLIENT_SECRET=xxxSTRAVA_CLIENT_ID
STRAVA_CLIENT_SECRET
GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET
PARKRUN_API_KEY
[vars]
STRAVA_REDIRECT_URI = "https://..."
STRAVA_CLUB_ID = "1234"Push to main branch triggers GitHub Actions:
- Runs database migrations
- Deploys Workers
- Builds and deploys frontend
# Deploy Workers
cd workers
npm run migrate:remote
npm run deploy
# Deploy Frontend
cd frontend
npm run build
npx wrangler pages deploy dist --project-name=your-projectThe sync system uses a queue-based approach to handle Strava's rate limits:
- Cron Trigger (Monday 2 AM): Adds all athletes to sync queue
- Queue Processor (every 2 minutes): Processes queued syncs
- Batch Processing: Large activity lists split into batches
- Rate Limiting: Respects 100 req/15min, 1000 req/day
Configuration in workers/src/cron/sync.ts:
const batchSize = 20; // Athletes per batch
const delayBetweenBatches = 60000; // 1 minute between batchesContributions are welcome! If you've improved this system for your club, please consider contributing back.
- Fork the repository
- Create a feature branch:
git checkout -b feature/your-feature - Make your changes
- Test locally
- Commit:
git commit -m "Add your feature" - Push:
git push origin feature/your-feature - Open a Pull Request
- Documentation improvements
- Bug fixes
- Performance optimizations
- New features (discuss in Issues first)
- Translations
MIT License - see LICENSE file for details.
- Documentation: This README and files in
docs/ - Issues: GitHub Issues
- Strava API: developers.strava.com/docs
- Cloudflare Workers: developers.cloudflare.com
Built with:
Estimated setup time: ~1 hour for complete deployment
Built by runners, for running clubs.