In Part 1, I shared how I built a voice-first AI tutor using Vapi, Next.js, and GPT-4. It was fast, expressive, and surprisingly helpful.
But it was also... wide open.
No user accounts. No access control. No usage limits. Just a playground.
Now it was time to turn that prototype into a real product — with authentication, protected dashboards, credit-based usage, and a way to know who’s actually using it.
Why Most AI MVPs Fail Without Access Control
Too often, early-stage AI tools ship as flashy demos without real product boundaries.
They impress on launch day — but fizzle fast:
- No login? You don’t know who your users are.
- No limits? Users spam GPT-4 at your cost.
- No structure? Hard to scale or monetize.
If you're building AI-first tools, productizing GPT isn't optional. The most successful AI apps treat access, usage, and UX as first-class features — not afterthoughts.
That’s what I set out to do next.
What I Needed to Ship
To turn Learnflow AI from demo to SaaS, I needed:
- Authentication — signup, login, logout
- Protected routes — dashboard, companions, sessions transcripts
- User records — to track sessions and credit usage
- Usage limits — to prevent abuse and encourage upgrades
Here's how I made that happen using Kinde for authentication, and Convex for backend logic + real-time data.
Why This Stack?
Concern | Tool | Why I Chose It |
---|---|---|
Auth + Billing | Kinde | Built-in auth, Google login, and billing APIs in one place |
Real-time backend | Convex | Reactive data, serverless logic, TypeScript-native |
Route protection | Next.js | App Router middleware makes gating simple |
Usage tracking | Convex | Easy credit logic using document database |
Bonus: Both tools are free to start, so I could move fast and test real usage.
Step 1: Set Up Auth with Kinde
Kinde handles all the hard parts of auth: login, sessions, social signup, and more.
Add your .env.local
config:
KINDE_CLIENT_ID=your_client_id KINDE_CLIENT_SECRET=your_client_secret KINDE_ISSUER_URL=https://yourproject.kinde.com NEXT_PUBLIC_KINDE_ISSUER_URL=https://yourproject.kinde.com KINDE_SITE_URL=http://localhost:3000 KINDE_POST_LOGIN_REDIRECT_URL=http://localhost:3000/dashboard KINDE_POST_LOGOUT_REDIRECT_URL=http://localhost:3000 NEXT_PUBLIC_KINDE_CONNECTION_EMAIL_CODE=your_email_code NEXT_PUBLIC_KINDE_CONNECTION_GOOGLE=your_google_code
Install the SDK:
npm install @kinde-oss/kinde-auth-nextjs
Step 2: Add Login, Signup, and Logout Flows
Kinde provides high-level components for login and registration, but I layered in our own custom UI to make the experience feel native to Learnflow AI.
Login Page (Email + Social)
I used LoginLink
from Kinde, which accepts authUrlParams
— letting us pass the user’s email, preferred connection method, and redirect destination.
To make login feel faster and more contextual, I tracked the email the user typed and used it as a login hint:
import { LoginLink } from "@kinde-oss/kinde-auth-nextjs/components"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; export function LoginForm() { const [email, setEmail] = useState(""); const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => { setEmail(e.target.value); }; return ( <div className="grid gap-6"> <div className="grid gap-3"> <Label htmlFor="email">Email</Label> <Input id="email" type="email" placeholder="m@example.com" value={email} onChange={handleEmailChange} required /> </div> <LoginLink authUrlParams={{ connection_id: process.env.NEXT_PUBLIC_KINDE_CONNECTION_EMAIL_CODE!, login_hint: email, post_login_redirect_url: `${window.location.origin}/dashboard`, }} > <Button className="w-full">Sign in with Email</Button> </LoginLink> </div> ); }
This meant the user only had to type their email once — no need to re-enter it on the hosted Kinde page.
I also provided a Google login option using the same LoginLink
but with the Google connection ID:
import { LoginLink } from "@kinde-oss/kinde-auth-nextjs/components"; import { Button } from "@/components/ui/button"; export function LoginForm() { return ( <LoginLink authUrlParams={{ connection_id: process.env.NEXT_PUBLIC_KINDE_CONNECTION_GOOGLE!, post_login_redirect_url: `${window.location.origin}/dashboard`, }} > <Button variant="outline" className="w-full"> Continue with Google </Button> </LoginLink> ); }
To keep users who were already authenticated from seeing the login page again, I used Kinde’s useKindeBrowserClient()
hook and redirected in a useEffect
:
import { useKindeBrowserClient } from "@kinde-oss/kinde-auth-nextjs"; export function LoginForm() { const router = useRouter(); const { isAuthenticated } = useKindeBrowserClient(); useEffect(() => { if (isAuthenticated) { router.push('/dashboard'); } }, [isAuthenticated]); }
Signup Page (With Pricing Table)
For the signup flow, I used RegisterLink
with an optional pricing_table_key
— which later shows users different plan tiers (handled in Part 3).
import { RegisterLink } from "@kinde-oss/kinde-auth-nextjs/components"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; export function SignupForm() { const [email, setEmail] = useState(""); const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => { setEmail(e.target.value); }; return ( <div className="grid gap-6"> <div className="grid gap-3"> <Label htmlFor="email">Email</Label> <Input id="email" type="email" placeholder="m@example.com" value={email} onChange={handleEmailChange} required /> </div> <RegisterLink authUrlParams={{ connection_id: process.env.NEXT_PUBLIC_KINDE_CONNECTION_EMAIL_CODE!, login_hint: email, pricing_table_key: "user_plans", }} > <Button className="w-full">Sign up with Email</Button> </RegisterLink> </div> ); }
The Google social signup follows the same structure but with RegisterLink
.
I added a redirect CTA for users who already had accounts:
<p className="text-sm text-center"> Already have an account?{' '} <button type="button" className="underline hover:text-primary" onClick={() => router.push("/auth#login")} > Sign in </button> </p>
This made the experience fluid, whether users were signing in via email, registering with social auth, or navigating between flows.
These small details — login hints, redirect control, and native UI — made the whole auth system feel tightly integrated, polished, and ties directly into your Kinde dashboard for analytics, metadata, and billing tier sync.
Next, I protected the routes behind those logins.
Step 3: Gate Routes Using Next.js Middleware + Kinde
Now that users can log in, I need to lock down private routes.
Using withAuth
from Kinde’s middleware package, I protected every route except public ones like /auth
, /about
, and /terms
.
Here’s the actual middleware.ts
:
import { withAuth } from "@kinde-oss/kinde-auth-nextjs/middleware"; import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; export const config = { matcher: [ '/((?!api|about|privacy|terms|_next/static|_next/image|images|favicon.ico|sitemap.xml|robots.txt|$).*)', ], } export default withAuth( function middleware(request: NextRequest) { const token = request.cookies.get('kinde_token'); const { pathname } = request.nextUrl; const isAuthPage = pathname === '/auth'; if (token && isAuthPage) { return NextResponse.redirect(new URL('/dashboard', request.url)); } return NextResponse.next(); }, { loginPage: '/auth', isReturnToCurrentPage: false } );
This setup ensures:
- Any route that’s not public is gated
- If an authenticated user tries to access
/auth
, they’re redirected to/dashboard
- Non-authenticated users hitting protected routes are sent to
/auth
You can also use getKindeServerSession() to get user info inside server actions or layouts — helpful for extra gating or fetching metadata.
Step 4: Define a Convex Schema
Time to store users, sessions, and credits. Convex makes this feel like defining TypeScript types:
import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; export default defineSchema({ users: defineTable({ email: v.string(), kindeId: v.string(), firstName: v.optional(v.string()), lastName: v.optional(v.string()), imageUrl: v.optional(v.string()), imageStorageId: v.optional(v.id("_storage")), credits.optional(v.number()), // represents the number of call minutes plan: v.optional(v.union( v.literal('free'), v.literal('pro'), v.literal('enterprise') )), features: v.optional(v.array(v.string())), // For storing individual feature flags companionLimit: v.optional(v.number()), // Optional: Cache the computed limit }), companions: defineTable({ userId: v.id("users"), name: v.string(), subject: v.string(), topic: v.string(), style: v.string(), voice: v.string(), duration: v.number(), author: v.string(), updatedAt: v.string(), }), sessions: defineTable({ userId: v.id("users"), companionId: v.id("companions"), updatedAt: v.string(), }), bookmarks: defineTable({ userId: v.id("users"), companionId: v.id("companions"), updatedAt: v.string(), }), });
Then run:
npx convex dev
This instantly creates a reactive, type-safe database.
Why It’s Designed This Way
- Users: Central store of identity + plan data, feature flags, companion limits
- Companions: Configurable voice tutors — each user can create many
- Sessions: Tracks interaction history, tied to user + companion
- Bookmarks: Lets users save or favorite a tutor — optional but great for UX
This gives you the structure to:
- Build personalized dashboards
- Track usage per user
- Control feature access by plan
- Scale the schema safely over time
I’ll use this schema to create user accounts, store sessions, and enforce usage logic.
Step 5: Create Users on First Login
Once a user logs in via Kinde, I insert their record into Convex (if it doesn’t exist):
const event = await validateKindeRequest(request); if (!event) { return new Response("Invalid request", { status: 400 }); } switch (event.type) { case "user.created": await ctx.runMutation(internal.users.create, { kindeId: event.data.user.id, email: event.data.user.email, firstName: event.data.user.first_name || "", lastName: event.data.user.last_name || "", imageUrl: event.data.user.image_url || "" }); break; }
export const create = internalMutation({ args: { kindeId: v.string(), email: v.string(), firstName: v.optional(v.string()), lastName: v.optional(v.string()), imageUrl: v.optional(v.string()), imageStorageId: v.optional(v.id("_storage")), credits v.optional(v.number)), plan: v.optional(v.union( v.literal('free'), v.literal('pro'), v.literal('enterprise') )), features: v.optional(v.array(v.string())), companionLimit: v.optional(v.number()) }, handler: async (ctx, args) => { try { const exists = await ctx.db .query("users") .filter((q) => q.eq(q.field("email"), args.email)) .unique(); if (exists) return exists; return await ctx.db.insert("users", { kindeId: args.kindeId, email: args.email, firstName: args.firstName || "", lastName: args.lastName || "", imageUrl: args.imageUrl, imageStorageId: args.imageStorageId, credits: args.credits || 10, plan: args.plan || 'free', features: args.features || [], companionLimit: args.companionLimit }); } catch (error) { console.error("Error creating user:", error); throw new ConvexError("Failed to create user."); } } });
I go into detail on how to properly set this up in this post.
Quick Detour: The Bug That Wiped Out All Credits
In an early test, I let users access the /sessions
route without checking credits. That meant even zero-credit users could trigger a GPT-4 call (and I footed the bill).
Lesson: always check usage before invoking your expensive APIs.
Step 6: Enforce Credit-Based Usage
Before each session, I call a Convex query to check the user's credit balance:
import { useKindeBrowserClient } from "@kinde-oss/kinde-auth-nextjs"; import { useQuery } from "convex/react"; import { api } from "@/convex/_generated/api"; const { user } = useKindeBrowserClient(); const userId = user?.id; const userData = useQuery( api.users.getUserKinde, userId ? { kindeId: userId } : "skip" ); if (userData.credits <= 0) { throw new Error("You’re out of credits. Please upgrade."); }
After a session:
import { useMutation } from "convex/react"; import { api } from "@/convex/_generated/api"; const deductCredit = useMutation(api.users.deductCredit); await deductCredit({ kindeId: userId, credits: userData.credits - 1 });
This simple pattern gives you:
- Trial flows
- Upgrade incentives
- Predictable usage costs
Bonus: Admin View to Edit Credits
I added an app/admin/dashboard/page.tsx
route that lets me manually reset or top-up credits for test users:
export const updateCredits = mutation({ args: { email: v.string(), credits: v.number() }, handler: async (ctx, args) => { const user = await ctx.db.query("users").filter(q => q.eq(q.field("email"), args.email)).unique(); if (!user) throw new Error("User not found"); return await ctx.db.patch("users", user._id, { credits: args.credits }); }, });
Folder Structure Snapshot
learnflow-ai/ ├── app/ │ ├── auth/ ← Login/signup/logout pages │ ├── (root)/ │ ├── dashboard/ ← Protected dashboard │ ├── companions/ ← Tutor configuration │ ├── sessions/ ← GPT-4 transcripts │ └── layout.tsx ├── convex/ │ ├── users.ts ← Credit logic │ ├── sessions.ts ← Conversation storage │ └── schema.ts ← DB structure ├── lib/ │ ├── utils.ts ← App helpers │ └── vapi.sdk.ts ← Vapi initialization ├── middleware.ts ← Route protection
What You Have Now
You’ve gone from playground to product:
- Auth with social login
- Access control via middleware
- User accounts + credit limits
- Real-time backend with zero infra
It’s structured. It’s secure. And it’s free to start.
Coming in Part 3: Monetization with Kinde Billing
In the next post, I will:
- Add paid subscriptions
- Gate features by plan
- Track user tier status
- Upgrade flows with Stripe-style billing
Final Thought
The sooner you treat your AI MVP like a product, the faster you’ll validate real usage.
Tools like Kinde and Convex let you do that — without wrestling a custom backend or building login from scratch.
You can ship real SaaS infrastructure in a weekend. I did.
Top comments (0)