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)