What We're Building
In this guide, we'll build a complete AI tutoring platform that adapts to how each student learns. The platform features real-time streaming chat with Claude, student progress tracking, adaptive difficulty levels, and personalized learning paths.
Streaming Chat
Real-time AI responses using Vercel AI SDK for a natural tutoring experience
Adaptive Learning
Difficulty adjusts based on quiz performance and learning pace
Progress Tracking
Comprehensive analytics on quiz results and knowledge gaps
Subject Knowledge
Context-aware tutoring with subject-specific knowledge bases
The tutor will remember conversation context, explain concepts at the right level for each student, generate practice quizzes, and provide detailed feedback on answers - all with the responsive feel of streaming responses.
Prerequisites
Before starting this guide, make sure you have the following:
- Node.js 18+ installed on your machine
- Anthropic API key for Claude access (get one here)
- Supabase account for database and authentication (free tier available)
- Vercel account for deployment (optional but recommended)
- Basic familiarity with React and TypeScript
Tech Stack Specification
Here's the technology stack we'll use for this build:
| Layer | Technology | Why This Choice |
|---|---|---|
| Frontend | Next.js 14, TypeScript | App router with built-in streaming support, server components |
| Backend | Vercel AI SDK | Built-in streaming support, easy integration with Claude |
| Database | Supabase (PostgreSQL) | Student progress tracking, learning history, quiz results |
| AI | Claude API (streaming) | Excellent at explanations, nuanced understanding, safe for education |
| Auth | Supabase Auth | Student accounts, session management, OAuth support |
| Hosting | Vercel | Edge streaming support, seamless Next.js deployment |
AI Agent Workflow
Here's how to leverage AI tools throughout this build to maximize productivity:
Streaming Chat with Claude Code
Use Claude Code for implementing the core streaming chat functionality, progress tracking logic, and database queries. Claude Code excels at writing the backend API routes and complex state management.
# Example prompt for Claude Code:
"Create a Next.js API route using Vercel AI SDK that:
1. Streams responses from Claude
2. Includes student learning profile in system prompt
3. Adjusts explanation complexity based on student level
4. Logs the conversation to Supabase for progress tracking"
UI Generation with v0.dev
Generate the chat interface, progress dashboard, and quiz components with v0.dev. Focus on creating responsive, accessible components that work well on both desktop and mobile.
When prompting v0.dev for the chat interface, specify "streaming message display with typing indicator" and "message bubbles with markdown rendering" to get components optimized for AI tutoring.
Development with Cursor
Use Cursor for fine-tuning the AI prompts, debugging stream handling, and implementing the adaptive difficulty algorithm. Cursor's inline AI is perfect for iterating on system prompts and fixing edge cases in the streaming logic.
Step-by-Step Build Guide
Phase 1: Project Setup with Vercel AI SDK
Let's start by creating a new Next.js project with all the necessary dependencies for streaming AI responses.
# Create Next.js project with TypeScript
npx create-next-app@latest ai-tutor --typescript --tailwind --eslint --app
# Navigate to project
cd ai-tutor
# Install dependencies
npm install ai @anthropic-ai/sdk @supabase/supabase-js @supabase/auth-helpers-nextjs
# Install dev dependencies
npm install -D @types/node
Create your environment variables file:
# Anthropic API
ANTHROPIC_API_KEY=your_anthropic_api_key
# Supabase
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key
Phase 2: Student Profile System
Set up the database schema and authentication for tracking student learning preferences and progress.
-- Student profiles with learning preferences
CREATE TABLE student_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
display_name TEXT,
learning_style TEXT CHECK (learning_style IN ('visual', 'auditory', 'reading', 'kinesthetic')),
difficulty_level INTEGER DEFAULT 1 CHECK (difficulty_level BETWEEN 1 AND 5),
subjects TEXT[] DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Quiz results for adaptive learning
CREATE TABLE quiz_results (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
student_id UUID REFERENCES student_profiles(id) ON DELETE CASCADE,
subject TEXT NOT NULL,
topic TEXT,
score INTEGER NOT NULL,
total_questions INTEGER NOT NULL,
difficulty INTEGER NOT NULL,
time_taken_seconds INTEGER,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Conversation history for context
CREATE TABLE conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
student_id UUID REFERENCES student_profiles(id) ON DELETE CASCADE,
subject TEXT,
messages JSONB DEFAULT '[]',
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Enable Row Level Security
ALTER TABLE student_profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE quiz_results ENABLE ROW LEVEL SECURITY;
ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;
Phase 3: Streaming Chat Interface
Now let's implement the core streaming chat functionality using the Vercel AI SDK with Claude.
import { anthropic } from '@ai-sdk/anthropic';
import { streamText } from 'ai';
import { createClient } from '@supabase/supabase-js';
export const maxDuration = 30;
interface StudentProfile {
learning_style: string;
difficulty_level: number;
subjects: string[];
}
function buildSystemPrompt(profile: StudentProfile, subject: string): string {
const levelDescriptions = {
1: 'beginner - use simple language, lots of examples, avoid jargon',
2: 'elementary - introduce basic terminology with clear definitions',
3: 'intermediate - assume foundational knowledge, use standard terminology',
4: 'advanced - dive deep into concepts, discuss edge cases',
5: 'expert - assume mastery, focus on nuance and advanced applications',
};
const styleGuidance = {
visual: 'Use diagrams, charts, and visual metaphors. Describe things spatially.',
auditory: 'Use rhythmic explanations, mnemonics, and verbal patterns.',
reading: 'Provide detailed written explanations with structured formatting.',
kinesthetic: 'Use hands-on examples, step-by-step procedures, and real-world applications.',
};
return `You are an expert AI tutor specializing in ${subject}.
STUDENT PROFILE:
- Learning Style: ${profile.learning_style}
- Current Level: ${levelDescriptions[profile.difficulty_level]}
TEACHING APPROACH:
${styleGuidance[profile.learning_style]}
GUIDELINES:
1. Adapt explanations to the student's level and learning style
2. Check for understanding before moving to new concepts
3. Provide practice problems when appropriate
4. Celebrate progress and encourage curiosity
5. If the student struggles, try a different explanation approach
6. Use markdown formatting for clarity (headers, lists, code blocks)
Be patient, encouraging, and thorough. Your goal is to help the student truly understand, not just memorize.`;
}
export async function POST(req: Request) {
const { messages, studentId, subject } = await req.json();
// Fetch student profile from Supabase
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
const { data: profile } = await supabase
.from('student_profiles')
.select('*')
.eq('id', studentId)
.single();
const systemPrompt = buildSystemPrompt(
profile || { learning_style: 'reading', difficulty_level: 2, subjects: [] },
subject || 'general education'
);
const result = streamText({
model: anthropic('claude-sonnet-4-20250514'),
system: systemPrompt,
messages,
});
return result.toDataStreamResponse();
}
Now create the chat UI component that handles streaming responses:
'use client';
import { useChat } from 'ai/react';
import { useState } from 'react';
import ReactMarkdown from 'react-markdown';
interface TutorChatProps {
studentId: string;
subject: string;
}
export function TutorChat({ studentId, subject }: TutorChatProps) {
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
api: '/api/chat',
body: { studentId, subject },
});
return (
<div className="flex flex-col h-[600px] max-w-3xl mx-auto">
{/* Messages Container */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((message) => (
<div
key={message.id}
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[80%] rounded-2xl px-4 py-3 ${
message.role === 'user'
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100'
}`}
>
{message.role === 'assistant' ? (
<ReactMarkdown className="prose dark:prose-invert prose-sm">
{message.content}
</ReactMarkdown>
) : (
<p>{message.content}</p>
)}
</div>
</div>
))}
{/* Typing Indicator */}
{isLoading && (
<div className="flex justify-start">
<div className="bg-gray-100 dark:bg-gray-800 rounded-2xl px-4 py-3">
<div className="flex space-x-2">
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-100" />
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-200" />
</div>
</div>
</div>
)}
</div>
{/* Input Form */}
<form onSubmit={handleSubmit} className="p-4 border-t dark:border-gray-700">
<div className="flex gap-2">
<input
value={input}
onChange={handleInputChange}
placeholder="Ask your tutor a question..."
className="flex-1 px-4 py-3 rounded-xl border dark:border-gray-700
bg-white dark:bg-gray-900 focus:outline-none focus:ring-2
focus:ring-blue-500"
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="px-6 py-3 bg-blue-600 text-white rounded-xl font-medium
hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed
transition-colors"
>
Send
</button>
</div>
</form>
</div>
);
}
Phase 4: Knowledge Base Integration
Add subject-specific context to make the tutor more knowledgeable. We'll create a simple knowledge base system that provides relevant context to Claude.
interface SubjectKnowledge {
overview: string;
keyTopics: string[];
commonMisconceptions: string[];
practiceAreas: string[];
}
export const knowledgeBase: Record<string, SubjectKnowledge> = {
mathematics: {
overview: `Mathematics is the study of numbers, quantities, shapes, and patterns.
Focus on building intuition before formulas.`,
keyTopics: [
'Algebra and equations',
'Geometry and spatial reasoning',
'Statistics and probability',
'Calculus fundamentals',
],
commonMisconceptions: [
'Negative times negative equals negative (it equals positive)',
'Order of operations confusion with parentheses',
'Confusing correlation with causation in statistics',
],
practiceAreas: [
'Word problems for applied understanding',
'Mental math exercises',
'Proof writing for logical reasoning',
],
},
programming: {
overview: `Programming is the art of giving computers precise instructions.
Focus on problem-solving and computational thinking.`,
keyTopics: [
'Variables and data types',
'Control flow (if/else, loops)',
'Functions and modularity',
'Data structures (arrays, objects)',
],
commonMisconceptions: [
'Assignment (=) vs comparison (==)',
'Off-by-one errors in loops',
'Mutability vs immutability confusion',
],
practiceAreas: [
'Code debugging exercises',
'Small project building',
'Code review and refactoring',
],
},
// Add more subjects as needed...
};
export function getSubjectContext(subject: string): string {
const knowledge = knowledgeBase[subject.toLowerCase()];
if (!knowledge) {
return '';
}
return `
SUBJECT KNOWLEDGE:
${knowledge.overview}
KEY TOPICS TO COVER:
${knowledge.keyTopics.map(t => `- ${t}`).join('\n')}
COMMON MISCONCEPTIONS TO ADDRESS:
${knowledge.commonMisconceptions.map(m => `- ${m}`).join('\n')}
PRACTICE RECOMMENDATIONS:
${knowledge.practiceAreas.map(p => `- ${p}`).join('\n')}
`;
}
Phase 5: Progress Tracking and Analytics
Implement the quiz system and progress tracking to enable adaptive difficulty. This data feeds back into the AI tutor's system prompt.
import { createClient } from '@supabase/supabase-js';
interface QuizResult {
studentId: string;
subject: string;
topic: string;
score: number;
totalQuestions: number;
difficulty: number;
timeTakenSeconds: number;
}
export async function recordQuizResult(result: QuizResult) {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
// Record the quiz result
await supabase.from('quiz_results').insert({
student_id: result.studentId,
subject: result.subject,
topic: result.topic,
score: result.score,
total_questions: result.totalQuestions,
difficulty: result.difficulty,
time_taken_seconds: result.timeTakenSeconds,
});
// Update difficulty level based on performance
await updateDifficultyLevel(result.studentId, result.subject);
}
async function updateDifficultyLevel(studentId: string, subject: string) {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
// Get recent quiz results for this subject
const { data: recentResults } = await supabase
.from('quiz_results')
.select('score, total_questions, difficulty')
.eq('student_id', studentId)
.eq('subject', subject)
.order('created_at', { ascending: false })
.limit(5);
if (!recentResults || recentResults.length < 3) {
return; // Need at least 3 results to adjust
}
// Calculate average performance
const avgPerformance = recentResults.reduce((acc, r) => {
return acc + (r.score / r.total_questions);
}, 0) / recentResults.length;
// Get current profile
const { data: profile } = await supabase
.from('student_profiles')
.select('difficulty_level')
.eq('id', studentId)
.single();
let newLevel = profile?.difficulty_level || 2;
// Adjust difficulty based on performance
if (avgPerformance >= 0.85 && newLevel < 5) {
newLevel++; // Increase difficulty if doing very well
} else if (avgPerformance < 0.5 && newLevel > 1) {
newLevel--; // Decrease difficulty if struggling
}
// Update profile
await supabase
.from('student_profiles')
.update({ difficulty_level: newLevel, updated_at: new Date().toISOString() })
.eq('id', studentId);
}
Phase 6: Adaptive Response System
Finally, let's tie everything together with an adaptive system that adjusts the tutor's behavior based on real-time student performance.
import { createClient } from '@supabase/supabase-js';
import { getSubjectContext } from './knowledge-base';
interface AdaptiveContext {
recentTopics: string[];
strugglingAreas: string[];
masteredTopics: string[];
suggestedApproach: string;
}
export async function getAdaptiveContext(
studentId: string,
subject: string
): Promise<AdaptiveContext> {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
// Get recent quiz results
const { data: results } = await supabase
.from('quiz_results')
.select('topic, score, total_questions')
.eq('student_id', studentId)
.eq('subject', subject)
.order('created_at', { ascending: false })
.limit(20);
const topicPerformance: Record<string, number[]> = {};
results?.forEach(r => {
if (!topicPerformance[r.topic]) {
topicPerformance[r.topic] = [];
}
topicPerformance[r.topic].push(r.score / r.total_questions);
});
const strugglingAreas: string[] = [];
const masteredTopics: string[] = [];
for (const [topic, scores] of Object.entries(topicPerformance)) {
const avgScore = scores.reduce((a, b) => a + b, 0) / scores.length;
if (avgScore < 0.6) {
strugglingAreas.push(topic);
} else if (avgScore >= 0.85) {
masteredTopics.push(topic);
}
}
// Generate approach suggestion
let suggestedApproach = '';
if (strugglingAreas.length > 0) {
suggestedApproach = `Focus on reviewing: ${strugglingAreas.join(', ')}.
Use more examples and check understanding frequently.`;
} else if (masteredTopics.length > 3) {
suggestedApproach = `Student shows mastery in ${masteredTopics.length} topics.
Challenge them with advanced problems and edge cases.`;
}
return {
recentTopics: Object.keys(topicPerformance).slice(0, 5),
strugglingAreas,
masteredTopics,
suggestedApproach,
};
}
Common Issues and Solutions
Here are some common issues you might encounter and how to solve them:
If streams disconnect unexpectedly, ensure your Vercel function timeout is set appropriately (30 seconds minimum) and implement client-side reconnection logic using the onError callback from useChat.
If database queries return empty when data exists, check your Row Level Security policies. For server-side operations, use the service role key which bypasses RLS.
Claude has context limits. Implement conversation summarization for long tutoring sessions - summarize older messages while keeping recent ones intact.
Next Steps
Congratulations on building your AI tutoring platform! Here are some ideas to take it further:
- Voice Integration: Add speech-to-text for verbal questions and text-to-speech for audio learners using Web Speech API or Whisper
- Multiplayer Study Rooms: Create real-time collaborative learning spaces where students can work together with AI assistance
- Spaced Repetition: Implement a review system that resurfaces topics at optimal intervals based on forgetting curves
- Parent/Teacher Dashboard: Build an analytics dashboard for educators to monitor student progress across subjects
- Content Generation: Have Claude generate custom worksheets, practice problems, and study guides based on identified weak areas
Follow the Vibe Coding Enthusiast
Follow JD — product updates on LinkedIn, personal takes on X.