Build a Duolingo-style language learning app with V0 using Next.js, Supabase, and shadcn/ui. Features spaced repetition flashcards with the SM-2 algorithm, progress streaks, deck browsing, and card flip animations — all with server-side scheduling logic to prevent cheating. Takes about 1-2 hours.
What you're building
Language learning apps are one of the most popular app categories, and spaced repetition is the scientifically proven method behind tools like Anki and Duolingo. Building your own gives you full control over the content, scheduling algorithm, and user experience.
V0 makes this feasible for non-developers by generating the flashcard UI, review logic, and deck management from prompts. Connect Supabase via the Connect panel for the database, auth, and audio file storage. Queue up prompts for the review engine, streak tracker, and deck browser to build all three features in one session.
The architecture uses a client component for the interactive flashcard flip, the SM-2 algorithm computed server-side in an API route to prevent manipulation, Supabase for progress tracking and deck storage, and Server Components for the dashboard and deck browser.
Final result
A spaced repetition language learning app with flashcard review sessions, SM-2 scheduling, streak tracking, public deck browsing, and audio pronunciation support.
Tech stack
Prerequisites
- A V0 account (Premium or higher for prompt queuing)
- A Supabase project (free tier works — connect via V0's Connect panel)
- Flashcard content for at least one language deck (front/back pairs)
- Basic understanding of flashcard-based learning (no coding needed)
Build steps
Set up the database schema for decks, cards, and progress
Create the Supabase schema for languages, decks, cards, user progress with SM-2 fields, and streak tracking. The user_progress table stores the spaced repetition state for each card per user.
1// Paste this prompt into V0's AI chat:2// Build a language learning app. Create a Supabase schema with these tables:3// 1. languages: id (uuid PK), code (text), name (text)4// 2. decks: id (uuid PK), language_id (uuid FK to languages), title (text), description (text), creator_id (uuid FK to auth.users), is_public (boolean DEFAULT false)5// 3. cards: id (uuid PK), deck_id (uuid FK to decks), front (text), back (text), audio_url (text), image_url (text)6// 4. user_progress: id (uuid PK), user_id (uuid FK to auth.users), card_id (uuid FK to cards), ease_factor (numeric DEFAULT 2.5), interval_days (int DEFAULT 1), repetitions (int DEFAULT 0), next_review_at (timestamptz), last_reviewed_at (timestamptz)7// 5. streaks: user_id (uuid PK FK to auth.users), current_streak (int DEFAULT 0), longest_streak (int DEFAULT 0), last_active (date)8// Add RLS policies: users can read public decks and their own progress.9// Generate SQL migration and TypeScript types.Pro tip: Use V0's prompt queuing to build the schema, review interface, and deck browser in sequence — queue up to 10 prompts without waiting.
Expected result: Supabase is connected with all five tables created. SM-2 fields (ease_factor, interval_days, repetitions, next_review_at) are ready for the algorithm.
Build the flashcard review interface with flip animation
Create the interactive review session page with a card flip animation, difficulty rating buttons, and session progress tracking. This is a 'use client' component because it needs click handlers and animation state.
1'use client'23import { useState } from 'react'4import { Card, CardContent } from '@/components/ui/card'5import { Button } from '@/components/ui/button'6import { Progress } from '@/components/ui/progress'78interface FlashCard {9 id: string10 front: string11 back: string12 audio_url?: string13}1415export function ReviewSession({ cards }: { cards: FlashCard[] }) {16 const [index, setIndex] = useState(0)17 const [flipped, setFlipped] = useState(false)18 const current = cards[index]19 const progress = ((index) / cards.length) * 1002021 async function handleRating(quality: number) {22 await fetch('/api/review', {23 method: 'POST',24 headers: { 'Content-Type': 'application/json' },25 body: JSON.stringify({ card_id: current.id, quality }),26 })27 setFlipped(false)28 setIndex((i) => i + 1)29 }3031 if (index >= cards.length) {32 return (33 <Card className="p-8 text-center">34 <CardContent>35 <h2 className="text-2xl font-bold">Session Complete!</h2>36 <p className="text-muted-foreground mt-2">You reviewed {cards.length} cards</p>37 </CardContent>38 </Card>39 )40 }4142 return (43 <div className="space-y-6">44 <Progress value={progress} />45 <div46 className="cursor-pointer perspective-1000"47 onClick={() => setFlipped(!flipped)}48 >49 <div className={`relative w-full h-64 transition-transform duration-500 transform-style-3d ${flipped ? 'rotate-y-180' : ''}`}>50 <Card className="absolute inset-0 backface-hidden flex items-center justify-center p-8">51 <CardContent className="text-2xl font-medium text-center">{current.front}</CardContent>52 </Card>53 <Card className="absolute inset-0 backface-hidden rotate-y-180 flex items-center justify-center p-8">54 <CardContent className="text-2xl font-medium text-center">{current.back}</CardContent>55 </Card>56 </div>57 </div>58 {flipped && (59 <div className="flex gap-2 justify-center">60 <Button variant="destructive" onClick={() => handleRating(1)}>Again</Button>61 <Button variant="outline" onClick={() => handleRating(3)}>Hard</Button>62 <Button variant="default" onClick={() => handleRating(4)}>Good</Button>63 <Button variant="secondary" onClick={() => handleRating(5)}>Easy</Button>64 </div>65 )}66 </div>67 )68}Expected result: The review session shows a flippable flashcard with difficulty rating buttons. Cards advance on rating, and a progress bar tracks session completion.
Implement the SM-2 spaced repetition algorithm server-side
Create the API route that receives a card ID and quality rating, computes the new SM-2 interval and ease factor, and updates the user's progress. Running this server-side prevents users from manipulating their review schedule.
1import { createClient } from '@supabase/supabase-js'2import { NextRequest, NextResponse } from 'next/server'34const supabase = createClient(5 process.env.SUPABASE_URL!,6 process.env.SUPABASE_SERVICE_ROLE_KEY!7)89function sm2(quality: number, repetitions: number, easeFactor: number, interval: number) {10 if (quality < 3) {11 return { repetitions: 0, interval: 1, easeFactor }12 }1314 let newInterval: number15 if (repetitions === 0) newInterval = 116 else if (repetitions === 1) newInterval = 617 else newInterval = Math.round(interval * easeFactor)1819 const newEase = Math.max(20 1.3,21 easeFactor + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02))22 )2324 return {25 repetitions: repetitions + 1,26 interval: newInterval,27 easeFactor: newEase,28 }29}3031export async function POST(req: NextRequest) {32 const { card_id, quality } = await req.json()3334 if (!card_id || quality === undefined || quality < 0 || quality > 5) {35 return NextResponse.json({ error: 'Invalid input' }, { status: 400 })36 }3738 const { data: progress } = await supabase39 .from('user_progress')40 .select('*')41 .eq('card_id', card_id)42 .single()4344 const current = progress || { repetitions: 0, ease_factor: 2.5, interval_days: 1 }45 const result = sm2(quality, current.repetitions, current.ease_factor, current.interval_days)4647 const nextReview = new Date()48 nextReview.setDate(nextReview.getDate() + result.interval)4950 const { error } = await supabase.from('user_progress').upsert({51 card_id,52 user_id: (await supabase.auth.getUser()).data.user?.id,53 ease_factor: result.easeFactor,54 interval_days: result.interval,55 repetitions: result.repetitions,56 next_review_at: nextReview.toISOString(),57 last_reviewed_at: new Date().toISOString(),58 })5960 if (error) return NextResponse.json({ error: error.message }, { status: 500 })61 return NextResponse.json({ next_review_at: nextReview.toISOString(), interval: result.interval })62}Expected result: The API route computes SM-2 scheduling and updates the next review date. Cards rated 'Again' reset to 1-day interval; 'Easy' cards get progressively longer intervals.
Build the learn dashboard with streak tracking
Create the main dashboard showing due card counts per deck, current streak, and quick-start buttons for review sessions. The streak updates automatically when users complete at least one review per day.
1// Paste this prompt into V0's AI chat:2// Create a learn dashboard at app/learn/page.tsx.3// Requirements:4// - Fetch due cards count (where next_review_at <= now) grouped by deck5// - Show streak info in a Card: flame icon, current_streak number, 'day streak' text, longest_streak below6// - Show a grid of deck Cards with: deck title, language Badge, due cards count in a red Badge, total cards, Progress bar for mastered percentage7// - Each deck Card has a 'Review Now' Button linking to /learn/[deckId]8// - If no cards are due, show a celebration message: 'All caught up! Come back tomorrow.'9// - Add a 'Browse Decks' Button linking to /decks for discovering new content10// - Update the streak: if last_active is yesterday, increment current_streak; if last_active is not yesterday or today, reset to 1; if today, do nothingPro tip: The streak logic runs as a Server Action when the dashboard loads. Compare last_active date with today to determine whether to increment, reset, or skip the streak update.
Expected result: The dashboard shows due card counts, streak tracking with flame icon, and quick-start review buttons for each deck.
Create the public deck browser and deploy
Build a browse page where users can discover public decks, preview cards, and add decks to their study list. Then configure environment variables and publish to production.
1// Paste this prompt into V0's AI chat:2// Create a deck browser at app/decks/page.tsx.3// Requirements:4// - Fetch all public decks from Supabase with card count and language name5// - Display as a shadcn/ui Card grid: deck title, language Badge, card count, description preview, creator name6// - Add search Input for filtering by title or language7// - Add a Drawer for deck detail: shows first 5 cards (front only), full description, and a 'Start Learning' Button8// - 'Start Learning' copies the deck to the user's progress and redirects to /learn/[deckId]9// - Use Server Components for the page with Supabase query, client component only for the search input and Drawer interaction10// Also create a Supabase Storage public bucket called 'audio' for pronunciation files.Expected result: The deck browser shows public decks with search filtering. Users can preview decks and start learning. The app is published to Vercel.
Complete code
1import { createClient } from '@supabase/supabase-js'2import { NextRequest, NextResponse } from 'next/server'34const supabase = createClient(5 process.env.SUPABASE_URL!,6 process.env.SUPABASE_SERVICE_ROLE_KEY!7)89function sm2(10 quality: number,11 repetitions: number,12 easeFactor: number,13 interval: number14) {15 if (quality < 3) {16 return { repetitions: 0, interval: 1, easeFactor }17 }18 let newInterval: number19 if (repetitions === 0) newInterval = 120 else if (repetitions === 1) newInterval = 621 else newInterval = Math.round(interval * easeFactor)2223 const newEase = Math.max(24 1.3,25 easeFactor + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02))26 )27 return { repetitions: repetitions + 1, interval: newInterval, easeFactor: newEase }28}2930export async function POST(req: NextRequest) {31 const { card_id, quality, user_id } = await req.json()3233 if (!card_id || quality < 0 || quality > 5) {34 return NextResponse.json({ error: 'Invalid input' }, { status: 400 })35 }3637 const { data: progress } = await supabase38 .from('user_progress')39 .select('*')40 .eq('card_id', card_id)41 .eq('user_id', user_id)42 .single()4344 const prev = progress || { repetitions: 0, ease_factor: 2.5, interval_days: 1 }45 const result = sm2(quality, prev.repetitions, prev.ease_factor, prev.interval_days)46 const nextReview = new Date()47 nextReview.setDate(nextReview.getDate() + result.interval)4849 await supabase.from('user_progress').upsert({50 user_id,51 card_id,52 ease_factor: result.easeFactor,53 interval_days: result.interval,54 repetitions: result.repetitions,55 next_review_at: nextReview.toISOString(),56 last_reviewed_at: new Date().toISOString(),57 })5859 return NextResponse.json({ next_review_at: nextReview, interval: result.interval })60}Customization ideas
Text-to-speech pronunciation
Integrate the Web Speech API or ElevenLabs for real-time pronunciation of the card front text, letting users hear words without pre-recorded audio files.
Sentence builder exercises
Add a drag-and-drop sentence construction mode where users arrange word tiles in the correct order, building on the flashcard vocabulary.
Leaderboard and social features
Create a weekly leaderboard ranking users by cards reviewed, streak length, or XP points earned from correct answers.
Deck creation wizard
Add a multi-step form for creating custom decks with bulk card import from CSV, image upload per card, and audio recording via the browser microphone.
Common pitfalls
Pitfall: Running the SM-2 algorithm on the client side
How to avoid: Compute the SM-2 algorithm in an API route (server-side). The client only sends the card_id and quality rating; the server calculates the new interval and updates the database.
Pitfall: Querying all cards instead of only due cards for review
How to avoid: Filter cards with .lte('next_review_at', new Date().toISOString()) to only fetch cards that are due for review today or earlier.
Pitfall: Using window or localStorage in the Server Component for streak data
How to avoid: Store streak data in Supabase, not localStorage. The Server Component fetches it from the database. For client-only features, use 'use client' directive or dynamic import with { ssr: false }.
Best practices
- Run the SM-2 algorithm server-side in an API route to prevent users from manipulating their review schedule
- Use Supabase Storage public bucket for audio files so they load quickly without signed URL overhead
- Use V0's prompt queuing to build the review engine, streak tracker, and deck browser in sequence without waiting between prompts
- Filter due cards with .lte('next_review_at', now) to only show cards that need review today — this is the core of spaced repetition
- Store streak data in Supabase rather than localStorage to persist across devices and prevent manipulation
- Use CSS perspective transforms for the card flip animation — it is GPU-accelerated and smooth on mobile devices
- Add a unique constraint on (user_id, card_id) in user_progress to ensure one progress record per card per user
- Use V0's Design Mode (Option+D) to adjust card sizes, button colors, and spacing for the review interface without spending credits
AI prompts to try
Copy these prompts to build this project faster.
I'm building a spaced repetition language learning app with Next.js and Supabase. I need help implementing the SM-2 algorithm. The function should take a quality rating (0-5), current repetitions count, ease factor (default 2.5), and current interval in days. It should return the new repetitions, interval, and ease factor. Quality < 3 means reset. Please write the TypeScript function and explain the math.
Create a flashcard flip component using CSS 3D transforms (perspective, rotateY, backface-visibility). The card should flip on click revealing the answer on the back. Below the flipped card, show four difficulty buttons: Again (red), Hard (outline), Good (default), Easy (secondary). Track the current card index and show a Progress bar for session completion. When all cards are reviewed, show a completion Card with stats.
Frequently asked questions
What is the SM-2 spaced repetition algorithm?
SM-2 is the algorithm used by Anki and similar apps. It adjusts review intervals based on how well you know a card. Cards you rate as 'Easy' are shown less frequently (longer intervals), while cards you rate as 'Again' reset to a 1-day interval. The ease factor adjusts over time to personalize the schedule.
Can users create and share their own decks?
Yes. The decks table has a creator_id field and an is_public boolean. Users create decks and cards through the creation form, and can toggle them as public. Public decks appear in the deck browser for other users to discover and study.
How does the streak tracking work?
The streaks table stores current_streak, longest_streak, and last_active date. When a user opens the dashboard, a Server Action compares last_active with today. If yesterday, it increments the streak. If more than one day ago, it resets to 1. If today, it does nothing.
Do I need a paid V0 plan?
Premium ($20/month) is recommended. The language learning app has multiple interactive components (flashcard flip, review session, deck browser, dashboard) that require several prompts to build. Prompt queuing on Premium lets you build faster.
Can I add audio pronunciation to flashcards?
Yes. Upload audio files to a Supabase Storage public bucket. The cards table has an audio_url field. In the review interface, add an audio play button that uses the HTML5 Audio API to play the pronunciation when the card is shown.
How do I deploy the language learning app?
Click Share in V0, then Publish to Production. Set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY in the Vars tab for client-side card fetching. The app deploys to Vercel in 30-60 seconds.
Can RapidDev help build a custom language learning app?
Yes. RapidDev has built over 600 apps including educational platforms with spaced repetition, gamification, and multi-language support. Book a free consultation to discuss your learning methodology and get a production-ready app.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation