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)