DEV Community

Cover image for How I Turned a Voice AI Demo Into a Real SaaS App — Auth, Access, and Limits
Shola Jegede
Shola Jegede Subscriber

Posted on

How I Turned a Voice AI Demo Into a Real SaaS App — Auth, Access, and Limits

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 
Enter fullscreen mode Exit fullscreen mode

Install the SDK:

npm install @kinde-oss/kinde-auth-nextjs 
Enter fullscreen mode Exit fullscreen mode

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> ); } 
Enter fullscreen mode Exit fullscreen mode

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> ); } 
Enter fullscreen mode Exit fullscreen mode

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]); } 
Enter fullscreen mode Exit fullscreen mode

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> ); } 
Enter fullscreen mode Exit fullscreen mode

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> 
Enter fullscreen mode Exit fullscreen mode

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 } ); 
Enter fullscreen mode Exit fullscreen mode

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(), }), }); 
Enter fullscreen mode Exit fullscreen mode

Then run:

npx convex dev 
Enter fullscreen mode Exit fullscreen mode

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; } 
Enter fullscreen mode Exit fullscreen mode
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."); } } }); 
Enter fullscreen mode Exit fullscreen mode

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."); } 
Enter fullscreen mode Exit fullscreen mode

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 }); 
Enter fullscreen mode Exit fullscreen mode

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 }); }, }); 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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)