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 • Edited 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)