I've been lifting for years. And I've tried every workout app out there. Strong. Hevy. FitNotes. Ladder. They all fall short in the same predictable way.
You're 30 seconds into your rest period, sweaty, trying to log a set, and the app wants you to:
- Tap "Add Set"
- Tap the weight field
- Wait for the keyboard
- Type the weight
- Dismiss the keyboard
- Tap the reps field
- Wait for the keyboard AGAIN
- Type the reps
- Dismiss the keyboard AGAIN
- Scroll to find the "Save" button
- Tap "Save"
By this point your rest timer expired 45 seconds ago and you're late for your next set.
The Problem Nobody Was Solving
Most workout apps treat logging like you're sitting at a desk with a cup of coffee and unlimited time.
Meanwhile, in the real world, you've got maybe 60-90 seconds between sets.
So we built OpenTrainer. Two taps per set.
The Tech Stack (And Why We Chose It)
Here's what we're running:
- Next.js 16
- Convex
- Clerk
- OpenRouter
- shadcn
- Vercel
Why Convex?
Convex is awesome for this use case.
Traditional workout apps use REST APIs. You tap "Log Set" → HTTP request → server processes → database write → HTTP response → UI updates. If you're on sketchy gym WiFi, that's 2-3 seconds of loading spinner hell.
Convex gives us real-time sync with zero setup. The mutation fires, the optimistic update shows instantly, and the actual database write happens in the background. If it fails, it rolls back automatically. If you go offline mid-workout, it queues the mutations and syncs when you're back online.
We have optimistic updates working with seven lines of code:
const logSet = useConvexMutation(api.workouts.addLiftingEntry);
const handleLogSet = () => {
logSet({
workoutId,
exerciseName: "Bench Press",
clientId: generateClientId(), // Deduplication
kind: "lifting",
lifting: { setNumber: 1, reps: 8, weight: 225, unit: "lb" }
});
// UI updates instantly. Done.
};
Convex handles:
- Optimistic UI updates
- Deduplication via
clientId - Offline queueing
- Conflict resolution
- Real-time sync across devices
The Schema
We use a discriminated union pattern for entries. One table, multiple exercise types:
// entries table
{
kind: "lifting" | "cardio" | "mobility",
lifting?: {
setNumber: number,
reps: number,
weight: number,
unit: "kg" | "lb",
rpe?: number
},
cardio?: {
durationSeconds: number,
distance?: number,
intensity?: number
},
mobility?: {
holdSeconds: number,
perSide?: boolean
}
}
This beats having separate tables for lifting_entries, cardio_entries, mobility_entries. Querying a workout's full history is a single query. This way if we want to add a new exercise category we just extend the union.
Next.js 16
What worked:
- Parallel routes made the bottom navigation clean
- Metadata API makes SEO easier
- Clerk's new
clerkMiddleware(v5+) works perfectly with Next.js 16
What hurt:
- The dev server randomly decides to recompile everything. Often.
- Hot reload occasionally needs a manual refresh
- Some Convex types needed manual casting (minor annoyance)
Auth Setup: Clerk + Convex requires a JWT template in the Clerk dashboard. Standard stuff, nothing custom:
// convex/auth.config.ts
export default {
providers: [{
domain: process.env.CLERK_JWT_ISSUER_DOMAIN!,
applicationID: "convex",
}],
}
You just need to grab your JWT issuer URL from Clerk and set it as an env var. That's it.
The AI Layer
Here's where we diverged from every other fitness app.
Most apps with "AI" just slap ChatGPT on a prompt and call it a day. We wanted something smarter.
Why OpenRouter Instead of Direct APIs
We use OpenRouter as our AI gateway. One API, multiple models. Right now we're using Gemini 3 Flash through OpenRouter's unified endpoint.
Why OpenRouter?
- No vendor lock-in - Switch models with one line of code
- Cost optimization - OpenRouter shows pricing for every model
- Fallback logic - Can implement automatic failover to Claude/GPT if Gemini is down
- One API key - Don't need separate Google, Anthropic, OpenAI accounts
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.OPENROUTER_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "google/gemini-3-flash-preview",
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userMessage },
],
}),
});
The Routine Generation Problem
Ask GPT-4 to generate a workout routine and you get:
- Generic "bro split" garbage
- Exercises you don't have equipment for
- Zero consideration for your actual goals
- Hallucinated exercise names ("reverse cable overhead skull-tricep extension")
We built an equipment audit during onboarding. User checks off what they have access to. Planet Fitness member? Cool, we know you have Smith machines and dumbbells, not barbells. Home gym? Tell us what you've got.
Then we pass that to Gemini with a structured prompt:
const routinePrompt = `
Generate a ${goal} program for ${experienceLevel} lifter.
EQUIPMENT AVAILABLE:
${equipment.join(", ")}
CONSTRAINTS:
- ${weeklyAvailability} days per week
- ${sessionDuration} minutes per session
- Must use ONLY the equipment listed above
- Use standard exercise names (no made-up movements)
OUTPUT FORMAT: JSON matching this schema...
`;
This works way better than you'd expect. Gemini's actually pretty good at constraint satisfaction when you give it clear boundaries.
The Training Load Calculation
Most apps calculate "volume" as sets × reps × weight. That's... fine. But it doesn't account for intensity.
We use a modified training load formula that factors in RPE (Rate of Perceived Exertion):
function calculateTrainingLoad(entry: Entry): number {
if (entry.kind !== "lifting") return 0;
const { reps = 0, weight = 0, rpe = 5 } = entry.lifting;
const volume = reps * weight;
const intensityFactor = rpe / 10;
return volume * intensityFactor;
}
A set of 5 reps at 225 lbs with RPE 9 (brutal) counts for more than 10 reps at 135 lbs with RPE 5 (warm-up). This gives us way better insights into actual training stress.
The UI Philosophy
Here's the entire interaction model for logging a set:
- Tap the weight number → Quick adjust (+5 lb) or type any value
- Tap "Log Set" → Done
We use large tap targets (minimum 48px). Everything important is in thumb-reach on a phone held one-handed. The rest timer starts automatically after you log a set. Haptic feedback on every interaction so you get confirmation even with sweaty hands.
The Stepper Component
Building good steppers for weight/rep adjustment was surprisingly tricky:
<SetStepper
value={weight}
onChange={setWeight}
step={5} // +/- 5 lbs per tap
min={0}
max={1000}
label="Weight"
unit="lb"
enableDirectInput // Tap the number to type
/>
We're using optimistic updates here too. The value updates in local state immediately, then fires a debounced mutation to Convex to persist it.
The Stuff That Broke
Problem #1: The Rest Timer Kept Getting Killed
Mobile browsers are aggressive about killing background JavaScript. We had users complaining the rest timer would stop if they switched to Spotify between sets.
Solution: Web Workers + Service Workers + loud-ass notifications.
// rest-timer-worker.ts
self.addEventListener("message", (e) => {
if (e.data.action === "start") {
const interval = setInterval(() => {
self.postMessage({ timeRemaining: getRemainingTime() });
}, 1000);
}
});
Plus we request notification permissions so we can send an annoying alert when rest time is up. It works. Users hate it. But they also don't miss their rest periods anymore.
Problem #2: Clerk JWT Validation Failing Randomly
Convex needs to validate Clerk JWTs. The issuer domain kept changing between clerk.accounts.dev and accounts.dev for a reason I'm still not clear on.
We set up a .env var for the issuer domain and made it configurable.
Problem #3: The "My Phone Died Mid-Workout" Problem
Users would start a workout, log 10 sets, phone dies, come back and... everything's gone.
We added a recovery mechanism:
// On app load, check for incomplete workout
const checkForIncompleteWorkout = async () => {
const activeWorkout = await getActiveWorkout(userId);
if (activeWorkout && activeWorkout.status === "in_progress") {
// Show "Resume workout?" dialog
showResumeDialog(activeWorkout);
}
};
Now if your phone dies, the workout stays in "in_progress" state. When you open the app again, it asks if you want to resume. Crisis averted.
What We'd Do Differently
1. User Testing Sooner
We built the "perfect" UI in our heads, then watched actual users struggle with it.
Try It
OpenTrainer is in alpha. It's free. No ads. No tracking pixels. Your data exports as JSON whenever you want.
Or clone the repo and run it yourself. It's Apache 2.0 licensed. Do whatever you want with it.
GitHub: house-of-giants/opentrainer
TLDR
We built a workout tracker that doesn't suck. Two taps per set. Real-time sync. AI that actually knows your gym has a Smith machine, not a barbell. Next.js + Convex + Clerk + OpenRouter.
The hardest parts weren't the tech. They were:
- Making everything feel instant (optimistic updates everywhere)
- Keeping timers alive on mobile (Web Workers + notifications)
- Building UI that works with sweaty hands (big tap targets, haptic feedback)
If you're building something similar, use Convex. Seriously. It's rad for real-time apps.
Top comments (0)