Intermediate ~5 hours

Build an AI Tutoring Platform

Create an AI-powered tutor that adapts to student learning styles with streaming responses, progress tracking, and personalized recommendations using Claude API and Vercel AI SDK.

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.

Pro Tip

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.

Terminal
# 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:

.env.local
# 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.

supabase/migrations/001_initial_schema.sql
-- 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.

app/api/chat/route.ts
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:

app/components/TutorChat.tsx
'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.

lib/knowledge-base.ts
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.

lib/progress-tracker.ts
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.

lib/adaptive-tutor.ts
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:

Stream Connection Drops

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.

Supabase RLS Blocking Queries

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.

Token Limits with Long Conversations

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