Build a language learning app in Lovable with spaced repetition flashcards (SM-2 algorithm), an LLM-powered conversation partner, and lesson progress tracking — all backed by Supabase. Users study flashcards with CSS flip animations, chat with an AI tutor in their target language, and track daily streaks and mastery across vocabulary decks.
What you're building
The SM-2 algorithm is the engine behind apps like Anki. For each card, it stores an ease factor (starts at 2.5), an interval (days until next review), and the next review date. When a user rates a card 1–4, the algorithm adjusts the interval and ease factor accordingly. Rating 1 (again) resets the interval to 1 day. Rating 4 (easy) multiplies the interval by the ease factor and increases the ease factor. This means cards you know well appear less and less frequently, while hard cards come back daily.
The LLM conversation partner is a Supabase Edge Function that proxies requests to OpenAI. The system prompt instructs GPT-4o to act as a native speaker of the target language, respond only in that language at the user's CEFR level, gently correct grammar mistakes in a follow-up sentence, and keep responses under 3 sentences. The Edge Function receives the conversation history array and current message, calls OpenAI, and streams the response back. The Edge Function keeps your OpenAI API key server-side.
Lessons are ordered collections of flashcards with a narrative structure: introduction text, then cards, then a quiz. Progress is tracked per user per lesson in a user_lesson_progress table. Completing a lesson awards XP and increments the streak counter if the user has studied today.
Final result
A fully functional language learning app with SM-2 flashcards, AI conversation practice, lesson tracking, and a mastery dashboard.
Tech stack
Prerequisites
- Lovable Pro account for Edge Function generation
- Supabase project with Edge Functions enabled
- OpenAI API key saved to Cloud tab → Secrets as OPENAI_API_KEY
- Supabase URL and anon key saved as VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY
- Basic understanding of how flashcard apps work (no algorithm knowledge needed)
Build steps
Set up the language learning database schema
Prompt Lovable to create all the tables needed: decks, cards, review logs, lesson structure, and user progress. The card_reviews table is the heart of the SM-2 system — it stores scheduling data per user per card.
1Create a language learning app database schema in Supabase with these tables:23- languages: id, name (e.g. 'Spanish'), code ('es'), flag_emoji, is_active4- decks: id, language_id, title, description, category (vocabulary|phrases|grammar), difficulty (A1|A2|B1|B2|C1), card_count (int), created_at5- cards: id, deck_id, front (text, target language), back (text, native language), pronunciation (text, optional), image_url (text, optional), tags (text array)6- card_reviews: id, user_id, card_id, ease_factor (float, default 2.5), interval_days (int, default 1), repetitions (int, default 0), next_review_at (timestamptz, default now()), last_rating (int 1-4), reviewed_at7- lessons: id, deck_id, title, body_text (text, lesson intro), order_index (int), xp_reward (int default 10)8- lesson_cards: id, lesson_id, card_id, order_index9- user_lesson_progress: id, user_id, lesson_id, status (not_started|in_progress|completed), completed_at10- user_stats: id (references auth.users), xp_total (int default 0), streak_days (int default 0), last_study_date (date), studied_today (bool)1112RLS policies:13- decks, cards, lessons, lesson_cards, languages: public SELECT, no user writes14- card_reviews: users can SELECT/INSERT/UPDATE their own rows (user_id = auth.uid())15- user_lesson_progress: users can SELECT/INSERT/UPDATE their own rows16- user_stats: users can SELECT/UPDATE their own row1718Create a Supabase RPC function get_due_cards(p_deck_id uuid, p_user_id uuid, p_limit int) that returns cards from the deck where card_reviews.next_review_at <= now() for this user, OR cards with no review row yet (new cards). Limit to p_limit results.Pro tip: Ask Lovable to also seed the database with 20–30 sample Spanish vocabulary cards in a 'Common Words' deck so you can test the flashcard flow immediately without manually entering content.
Expected result: All tables are created with correct RLS policies. The get_due_cards RPC function is ready. TypeScript types are generated. The app shell renders in the preview.
Build the flashcard component with CSS flip animation
Create the core Flashcard component with a 3D CSS flip animation. The card shows the front (target language word), and clicking flips it to reveal the back (translation, pronunciation). After seeing the back, rating buttons appear for the SM-2 algorithm.
1Build a Flashcard component at src/components/Flashcard.tsx.23Requirements:4- The card renders a 3D flip using CSS: outer div with perspective: 1000px, inner div with transform-style: preserve-3d and a transition on transform: rotateY(180deg)5- Front face and back face are absolutely positioned, back face has backface-visibility: hidden and initial rotateY(180deg)6- On click, toggle a boolean isFlipped state. When flipped, rotate inner div to rotateY(180deg)7- Front shows: card.front in large text (2xl bold), card.pronunciation in smaller muted text below8- Back shows: card.back in large text, an optional image if card.image_url exists, and a Tags section showing card.tags as Badges9- When isFlipped is true AND the card has not been rated yet, show four rating Buttons below the card: 'Again (1)', 'Hard (2)', 'Good (3)', 'Easy (4)' with colors: red, orange, blue, green10- On rating button click, call onRate(rating: 1 | 2 | 3 | 4) prop11- The component accepts props: card: Card, onRate: (rating: number) => void, isLast: boolean12- Use Framer Motion AnimatePresence for the entrance/exit of the card (slide in from right, slide out to left when rated)Pro tip: Use CSS custom properties for the flip transition duration (--flip-duration: 0.4s) so you can easily add a settings option for users who prefer faster or slower animations.
Expected result: The Flashcard component renders with a smooth 3D flip on click. Rating buttons appear only after flipping. The card slides in and out with Framer Motion transitions.
Implement the SM-2 algorithm and review session
Build the study session page that fetches due cards, displays them one by one, and applies the SM-2 algorithm to update each card's schedule after the user rates it.
1// src/lib/sm2.ts2export interface ReviewData {3 easeFactor: number4 intervalDays: number5 repetitions: number6}78export function calculateNextReview(9 current: ReviewData,10 rating: 1 | 2 | 3 | 411): ReviewData & { nextReviewAt: Date } {12 let { easeFactor, intervalDays, repetitions } = current1314 if (rating < 3) {15 // Incorrect or hard — reset16 repetitions = 017 intervalDays = 118 } else {19 if (repetitions === 0) {20 intervalDays = 121 } else if (repetitions === 1) {22 intervalDays = 623 } else {24 intervalDays = Math.round(intervalDays * easeFactor)25 }26 repetitions += 127 }2829 // Update ease factor (minimum 1.3)30 easeFactor = Math.max(31 1.3,32 easeFactor + 0.1 - (4 - rating) * (0.08 + (4 - rating) * 0.02)33 )3435 const nextReviewAt = new Date()36 nextReviewAt.setDate(nextReviewAt.getDate() + intervalDays)3738 return { easeFactor, intervalDays, repetitions, nextReviewAt }39}Pro tip: After each session, call a Supabase RPC update_user_streak that checks if user_stats.last_study_date was yesterday and increments streak_days, or resets it to 1 if more than a day has passed.
Expected result: The study session shows cards one by one. Rating a card calls calculateNextReview, upserts the card_reviews row, and advances to the next card. A session summary shows XP earned.
Build the LLM conversation partner via Edge Function
Create a Supabase Edge Function that acts as a language tutor. It takes a conversation history and target language as input, calls OpenAI GPT-4o with a structured system prompt, and streams the response back to the Lovable frontend.
1// supabase/functions/conversation-partner/index.ts2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'34const corsHeaders = {5 'Access-Control-Allow-Origin': '*',6 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',7}89serve(async (req: Request) => {10 if (req.method === 'OPTIONS') {11 return new Response('ok', { headers: corsHeaders })12 }1314 try {15 const { messages, targetLanguage, cefrLevel, nativeLanguage } = await req.json()1617 const systemPrompt = `You are a friendly native ${targetLanguage} speaker helping a ${cefrLevel} level student practice conversation.18Rules:191. Always respond in ${targetLanguage} only202. Keep responses to 1-3 sentences, appropriate for ${cefrLevel} level213. If the user makes a grammar mistake, respond naturally then add one sentence in ${nativeLanguage}: "Grammar tip: [correction]"224. Ask a follow-up question to keep the conversation going235. Use vocabulary appropriate for ${cefrLevel} level`2425 const response = await fetch('https://api.openai.com/v1/chat/completions', {26 method: 'POST',27 headers: {28 'Authorization': `Bearer ${Deno.env.get('OPENAI_API_KEY')}`,29 'Content-Type': 'application/json',30 },31 body: JSON.stringify({32 model: 'gpt-4o',33 messages: [34 { role: 'system', content: systemPrompt },35 ...messages,36 ],37 max_tokens: 300,38 temperature: 0.7,39 }),40 })4142 const data = await response.json()43 const reply = data.choices?.[0]?.message?.content ?? 'Lo siento, try again.'4445 return new Response(46 JSON.stringify({ reply }),47 { headers: { ...corsHeaders, 'Content-Type': 'application/json' } }48 )49 } catch (err) {50 const message = err instanceof Error ? err.message : 'Unknown error'51 return new Response(52 JSON.stringify({ error: message }),53 { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }54 )55 }56})Expected result: The Edge Function deploys and accepts POST requests with messages array and language settings. The frontend Chat page calls it and displays the AI tutor's response in the conversation thread.
Build the mastery dashboard and lesson browser
Create the main app pages: a home dashboard showing streak, XP, and cards due today, a deck browser with categories, and a lessons list. The mastery view groups the user's cards by stage.
1Build two pages:231. Home dashboard at src/pages/Home.tsx:4- Top section: streak flame icon + streak_days count, XP total with a level badge (level = Math.floor(xp/100))5- 'Study Now' Card: shows count of cards due today from get_due_cards across all decks. Large Button links to /study6- Recent activity: last 5 card_reviews showing card.front, rating as colored Badge, and relative timestamp7- Mastery breakdown: four Badge counts for cards at each stage (New = no review, Learning = repetitions 1-2, Review = repetitions 3-5, Mastered = repetitions > 5)892. Deck browser at src/pages/Decks.tsx:10- Filter Tabs by category: All, Vocabulary, Phrases, Grammar11- Each deck as a Card with: title, description, difficulty Badge (A1=green through C1=red), card count, a circular Progress indicator showing user's mastered/total cards12- Clicking a deck opens /deck/:id showing the lesson list for that deck13- Each lesson row shows: title, status icon (locked/in-progress/completed), XP reward Badge14- Completed lessons show a checkmark. Locked lessons are grayed out (only unlock sequentially).Expected result: The home dashboard shows live streak, XP, and due card count from Supabase. The deck browser renders all decks with progress indicators. Lessons unlock sequentially as previous ones are completed.
Complete code
1export interface ReviewData {2 easeFactor: number3 intervalDays: number4 repetitions: number5}67export interface NextReview extends ReviewData {8 nextReviewAt: Date9 stage: 'learning' | 'review' | 'mastered'10}1112export function calculateNextReview(13 current: ReviewData,14 rating: 1 | 2 | 3 | 415): NextReview {16 let { easeFactor, intervalDays, repetitions } = { ...current }1718 if (rating < 3) {19 repetitions = 020 intervalDays = 121 } else {22 if (repetitions === 0) {23 intervalDays = 124 } else if (repetitions === 1) {25 intervalDays = 626 } else {27 intervalDays = Math.round(intervalDays * easeFactor)28 }29 repetitions += 130 }3132 // SM-2 ease factor formula33 easeFactor = Math.max(34 1.3,35 easeFactor + 0.1 - (4 - rating) * (0.08 + (4 - rating) * 0.02)36 )3738 const nextReviewAt = new Date()39 nextReviewAt.setDate(nextReviewAt.getDate() + intervalDays)4041 const stage =42 repetitions <= 2 ? 'learning' :43 repetitions <= 5 ? 'review' : 'mastered'4445 return { easeFactor, intervalDays, repetitions, nextReviewAt, stage }46}4748export function getDefaultReviewData(): ReviewData {49 return {50 easeFactor: 2.5,51 intervalDays: 1,52 repetitions: 0,53 }54}5556export function isDueToday(nextReviewAt: string | Date): boolean {57 const reviewDate = new Date(nextReviewAt)58 const now = new Date()59 return reviewDate <= now60}Customization ideas
Audio pronunciation with text-to-speech
Add a speaker icon to each flashcard front that calls a Supabase Edge Function wrapping OpenAI's TTS API. The Edge Function receives the word and target language, calls gpt-4o-audio or tts-1, and returns an audio/mpeg stream. Cache the audio in Supabase Storage so repeat listens don't incur API costs.
Writing practice mode
Add a 'Write' study mode alongside the flip mode. Instead of clicking to flip, the user types the translation in a text Input. Use a simple fuzzy match (remove accents, lowercase) to accept near-correct answers. Track writing accuracy separately and show a 'Typed vs Recognized' stat in the dashboard.
Leaderboard with weekly XP
Add a weekly_xp column to user_stats that resets every Monday via a pg_cron job. Build a public Leaderboard page that shows the top 20 users by weekly_xp. Use Supabase Realtime subscriptions so the leaderboard updates live as users earn XP.
Sentence mining from articles
Add an Article Reader page where users paste a target-language article. An Edge Function calls GPT-4o to extract unfamiliar vocabulary with definitions and example sentences, then lets the user add selected words directly to their deck as new cards.
Grammar lesson templates
Add a grammar lesson type with a different structure: explanation text, fill-in-the-blank exercises stored as JSON, and multiple-choice questions. Track grammar lesson completions separately from vocabulary and show a grammar mastery percentage on the dashboard.
Common pitfalls
Pitfall: Storing the SM-2 schedule in component state instead of Supabase
How to avoid: Upsert the card_reviews row in Supabase immediately after each rating using supabase.from('card_reviews').upsert({ user_id, card_id, ...nextReview, reviewed_at: new Date() }, { onConflict: 'user_id,card_id' }). Use a unique constraint on (user_id, card_id) to ensure one row per user per card.
Pitfall: Calling the OpenAI API directly from the Lovable frontend
How to avoid: Always proxy OpenAI calls through the Supabase Edge Function. Store OPENAI_API_KEY in Cloud tab → Secrets (without VITE_ prefix) and access it only in the Deno Edge Function with Deno.env.get('OPENAI_API_KEY').
Pitfall: Not adding RLS to card_reviews, allowing users to see each other's progress
How to avoid: Add a row-level security policy: CREATE POLICY reviews_own ON card_reviews USING (user_id = auth.uid()). Test it by logging in as two different users and verifying each only sees their own reviews.
Pitfall: Fetching all cards in a deck instead of only due cards for the study session
How to avoid: Use the get_due_cards(deck_id, user_id, limit) Supabase RPC function which applies the next_review_at filter on the database. Fetch 20 cards at a time and load more as the session progresses.
Best practices
- Store ease_factor as a float in the database, not a rounded integer. The SM-2 formula depends on small incremental changes that get rounded away if you use integers.
- Use an upsert on (user_id, card_id) for card_reviews rather than insert. This prevents duplicate rows if the user somehow rates the same card twice in a session.
- Cap the maximum interval in your SM-2 implementation at 180 days. The algorithm can produce very long intervals for easy cards, and a 6-month gap is practically useless for language retention.
- Keep the conversation partner system prompt concise. GPT-4o counts system prompt tokens against your quota on every message — a 500-token system prompt on 1,000 messages costs the same as 500,000 tokens of input.
- Add an index on card_reviews(user_id, next_review_at) to make the due-card query fast as the table grows: CREATE INDEX idx_reviews_due ON card_reviews(user_id, next_review_at).
- Show the user their session stats (cards reviewed, accuracy, XP earned) on a completion screen before returning to the dashboard. This reinforces the study habit loop.
- Limit conversation history sent to GPT-4o to the last 10 messages to control token costs while maintaining enough context for a coherent conversation.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a spaced repetition flashcard app using the SM-2 algorithm. I have a card_reviews table in Supabase with columns: ease_factor (float), interval_days (int), repetitions (int), next_review_at (timestamptz), last_rating (int 1-4). Write a TypeScript function calculateNextReview that takes current ReviewData and a rating 1-4 and returns the updated ease_factor, interval_days, repetitions, and next_review_at date. Follow the original SM-2 specification including the minimum ease factor of 1.3.
Add a vocabulary progress chart to the mastery dashboard. Show a stacked bar chart using Recharts where each bar represents one deck and the bar is divided into four segments: New (gray), Learning (yellow), Review (blue), Mastered (green). Fetch the data by joining cards with card_reviews for the current user, grouping by deck and computing stage from the repetitions column. Show counts in a Tooltip on hover.
In Supabase, create a scheduled Edge Function that runs daily at midnight UTC. It should identify users who have cards due in the next 24 hours (card_reviews.next_review_at between now() and now() + interval '24 hours') and have not studied today (user_stats.last_study_date < current_date). For each such user, call the Supabase Auth admin API to get their email, then send a reminder email using Resend. Return a JSON summary of how many emails were sent.
Frequently asked questions
Can I use a language other than Spanish as the target language?
Yes. The languages table stores any language you add. The conversation partner Edge Function receives the targetLanguage string dynamically — just populate your languages table with the languages you want to support and the system prompt adapts automatically. The SM-2 algorithm and flashcard UI are language-agnostic.
How does the SM-2 algorithm decide when to show each card again?
After each rating, the algorithm calculates a new interval in days and an ease factor. Rating 1 (Again) resets the card to 1-day interval. Rating 4 (Easy) multiplies the current interval by the ease factor and increases the ease factor. Over time, cards you consistently rate as Easy may only appear every few months, while cards you find Hard come back the next day.
How much does the GPT-4o conversation partner cost?
GPT-4o costs approximately $0.005 per 1,000 input tokens and $0.015 per 1,000 output tokens. A typical conversation exchange (system prompt + history + response) uses roughly 400–800 tokens. At 100 conversations per day, your daily cost would be under $1. Monitor usage in your OpenAI dashboard and set a monthly spending limit to avoid surprises.
What happens to a user's progress if they delete and recreate their account?
Progress is stored in card_reviews, user_lesson_progress, and user_stats — all linked to auth.uid(). If the user deletes their account, this data is deleted by Supabase's cascade rules (if you set up the foreign key correctly). To prevent accidental loss, add a soft-delete flag to users or export progress to a JSON file before deletion. Supabase Auth does not automatically delete related database rows unless you add cascade.
How do I add my own vocabulary decks and cards to the app?
After building the app, use the Supabase Table Editor to insert rows into the decks and cards tables. For bulk import, ask Lovable to add an admin-only CSV import page that parses a spreadsheet with columns front, back, pronunciation, deck_id and inserts them as cards. Add an is_admin boolean to user_stats and check it before showing the admin import UI.
Can the conversation partner remember vocabulary the user has studied?
By default the conversation history is only the last 10 messages sent to GPT-4o. To make it vocabulary-aware, modify the Edge Function to fetch the user's top 20 mastered cards from Supabase and include them in the system prompt as: 'The user has recently mastered these words: [list]. Try to use them naturally in your responses.' This keeps the conversation contextually relevant to their deck.
Is RapidDev available to help customize this app?
Yes. RapidDev builds production Lovable apps including custom learning platforms with advanced features like audio pronunciation, AI writing graders, and multi-language support. Reach out if you need help taking this beyond the starter build.
How do I prevent users from burning through all their cards in one sitting?
Add a daily_limit column to user_stats (default 50 cards per day). Before each study session, count how many cards the user has already reviewed today from card_reviews where reviewed_at >= today. If they've hit the limit, show a 'Come back tomorrow' screen with a count of cards due the next day. This improves retention by spreading study sessions.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation