When you build fast, you feel unstoppable. Weekend sprints. Prototype by Friday, live by Sunday. That was my entire approach with Learnflow AI.
And on the surface? It worked.
People signed up. Voice sessions ran smoothly. The tech stack (Next.js + Convex + Kinde + Vapi) held its own.
But a few weeks in, the cracks started to show.
Not in the UI. Not in the feature set.
But underneath.
In the assumptions I baked into my backend. In the flows I hardcoded to get a "demo" feel. In how little room I left for edge cases, upgrades, re-authentications, and returning users.
This is the post I wish I read before shipping that MVP.
It’s not about the speed. It’s about what gets left behind.
How It Started: Learnflow AI in One Weekend
The idea was simple:
Let anyone create a voice-based AI tutor that felt custom and responsive.
Stack
- Vapi: voice-to-transcript + agent logic
- Convex: backend DB + real-time mutations
- Kinde: authentication + hosted pricing page
- Next.js App Router: frontend + routes
Within 48 hours, I had it working:
- Sign up
- Pick plan (via Kinde)
- Land on dashboard
- Click "Create Tutor"
- Click "Start Session"
It worked. Mostly.
What I Didn't See Coming
There’s a cost to optimizing for "works now."
Here were the blind spots that crept up:
1. Onboarding State Wasn't Persistent
I had a stepper for onboarding users through their first tutor creation. It showed once. But if they refreshed or came back later?
They started from scratch.
2. Upgrade Logic Was Too Far Removed
Kinde hosted my pricing table, and plan info was saved to user metadata. But in-app logic had to manually read and check that. I had upgrade prompts that didn’t appear at the right time, or sometimes, not at all.
3. Session Tracking Wasn’t Modular
I tracked session starts and deducted credits... but not always consistently. Retry flows, dropped connections, or users switching devices would cause edge case issues.
How I Fixed It: Layer by Layer
1. Onboarding State Tracking via Convex
Instead of local state or front-end-only logic, I moved onboarding step tracking to the backend.
export const setOnboardingStep = mutation({
args: { userId: v.id("users"), step: v.string() },
handler: async (ctx, args) => {
await ctx.db.patch(args.userId, { onboardingStep: args.step });
},
});
Now, no matter where a user logs in from, I can:
- Resume onboarding
- Branch logic based on their last seen step
- Trigger upgrade nudges at the right moment
Diagram: Before vs After - Onboarding Flow
2. Credit Enforcement as Middleware
Before:
- I checked user credits manually inside button click handlers
- Edge cases like double calls or dropped sessions weren’t covered
Now:
- I wrapped credit checks inside a dedicated mutation
- That mutation runs before any session can start
export const canStartSession = query({
args: { userId: v.id("users") },
handler: async (ctx, args) => {
const user = await ctx.db.get(args.userId);
const isPro = user?.plan === "pro";
const hasCredits = user?.credits > 0;
return isPro || hasCredits;
},
});
Used in frontend:
const allowed = await api.sessions.canStartSession({ userId });
if (!allowed) return showUpgradeModal();
Diagram: Credit Check Flow
3. Modular Session Tracking
Old pattern:
-
startSession
just updated credits - But didn’t store metadata: which tutor, when, from which device
New pattern:
export const addSession = mutation({
args: {
userId: v.id("users"),
companionId: v.id("companions"),
timestamp: v.optional(v.string()),
},
handler: async (ctx, args) => {
await ctx.db.insert("sessions", {
userId: args.userId,
companionId: args.companionId,
});
},
});
This lets me:
- See per-user history
- Trigger upgrade nudges based on behavior
- Visualize usage per tutor
Better Architecture Patterns I Adopted
Feature Flags
Instead of if (user.plan === "pro")
, I now use:
function hasFeature(user, feature) {
if (user.plan === "pro") return true;
return freePlanFeatures.includes(feature);
}
Cleaner. Easier to maintain. Centralized.
Event Hooks
I wanted better separation between action and reaction.
Instead of hardcoding showUpgradeModal()
after every failed session start, I used an event emitter pattern:
events.on("session-blocked", () => {
showUpgradeModal();
});
That means I can swap UI reactions later without touching business logic.
Composable Logic: Credit + Plan
Rather than spreading plan logic in dozens of places, I now compose it.
function canUseFeature(user, feature) {
if (user.plan === "pro") return true;
if (!user.credits || user.credits <= 0) return false;
return freePlanFeatures.includes(feature);
}
Kinde: What Worked, What Didn't
✅ What Worked
- Hosted pricing pages = instant flow
- Metadata sync (plan info in session)
- Easy plan switching between free and pro
⚠️ What Needed Work
- Kinde metadata isn’t real-time (had to sync manually on plan switch)
- No built-in usage enforcement
- Needed my own gating via Convex queries
Still, Kinde handled the identity layer well. I just had to build the enforcement layer.
What I'd Do Differently (If Starting Today)
-
Design credit flows from day one
- Users don’t read docs
- If usage isn’t visible, no one upgrades
-
Track onboarding + session state in backend
- Frontend-only state is fragile
-
Make upgrade logic reactive, not static
- Trigger prompts based on what users do, not what page they’re on
-
Modularize credit checks
- Never rely on frontend logic alone
-
Plan for role-based UI early
- Gating with plan tiers helps prevent overuse
Final Thoughts
MVP speed is great.
But every speed-up becomes a tradeoff you’ll have to clean up.
That’s not a failure. It’s just how startups work.
If you're where I was a few weeks ago:
- Launching something fast
- Trying to stay lean
- Unsure how deep to go in pricing logic
Just remember:
Ship fast, but make space for your future self.
Make credit systems, upgrade prompts, and session enforcement composable.
Make onboarding state persistent.
And make it so that your second-time users aren’t starting from scratch.
Got questions or shipping your own AI MVP?
Drop a comment or DM me. I'm building Learnflow AI in public, powered by Kinde, Convex, Vapi, and lessons like these.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.