# How to Build Language learning app with V0

- Tool: v0
- Difficulty: Intermediate
- Compatibility: V0 Premium or higher
- Last updated: April 2026

## TL;DR

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.

## Before you start

- 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)

## Step-by-step guide

### 1. 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.

```
// Paste this prompt into V0's AI chat:
// Build a language learning app. Create a Supabase schema with these tables:
// 1. languages: id (uuid PK), code (text), name (text)
// 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)
// 3. cards: id (uuid PK), deck_id (uuid FK to decks), front (text), back (text), audio_url (text), image_url (text)
// 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)
// 5. streaks: user_id (uuid PK FK to auth.users), current_streak (int DEFAULT 0), longest_streak (int DEFAULT 0), last_active (date)
// Add RLS policies: users can read public decks and their own progress.
// 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.

### 2. 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.

```
'use client'

import { useState } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'

interface FlashCard {
  id: string
  front: string
  back: string
  audio_url?: string
}

export function ReviewSession({ cards }: { cards: FlashCard[] }) {
  const [index, setIndex] = useState(0)
  const [flipped, setFlipped] = useState(false)
  const current = cards[index]
  const progress = ((index) / cards.length) * 100

  async function handleRating(quality: number) {
    await fetch('/api/review', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ card_id: current.id, quality }),
    })
    setFlipped(false)
    setIndex((i) => i + 1)
  }

  if (index >= cards.length) {
    return (
      <Card className="p-8 text-center">
        <CardContent>
          <h2 className="text-2xl font-bold">Session Complete!</h2>
          <p className="text-muted-foreground mt-2">You reviewed {cards.length} cards</p>
        </CardContent>
      </Card>
    )
  }

  return (
    <div className="space-y-6">
      <Progress value={progress} />
      <div
        className="cursor-pointer perspective-1000"
        onClick={() => setFlipped(!flipped)}
      >
        <div className={`relative w-full h-64 transition-transform duration-500 transform-style-3d ${flipped ? 'rotate-y-180' : ''}`}>
          <Card className="absolute inset-0 backface-hidden flex items-center justify-center p-8">
            <CardContent className="text-2xl font-medium text-center">{current.front}</CardContent>
          </Card>
          <Card className="absolute inset-0 backface-hidden rotate-y-180 flex items-center justify-center p-8">
            <CardContent className="text-2xl font-medium text-center">{current.back}</CardContent>
          </Card>
        </div>
      </div>
      {flipped && (
        <div className="flex gap-2 justify-center">
          <Button variant="destructive" onClick={() => handleRating(1)}>Again</Button>
          <Button variant="outline" onClick={() => handleRating(3)}>Hard</Button>
          <Button variant="default" onClick={() => handleRating(4)}>Good</Button>
          <Button variant="secondary" onClick={() => handleRating(5)}>Easy</Button>
        </div>
      )}
    </div>
  )
}
```

**Expected result:** The review session shows a flippable flashcard with difficulty rating buttons. Cards advance on rating, and a progress bar tracks session completion.

### 3. 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.

```
import { createClient } from '@supabase/supabase-js'
import { NextRequest, NextResponse } from 'next/server'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

function sm2(quality: number, repetitions: number, easeFactor: number, interval: number) {
  if (quality < 3) {
    return { repetitions: 0, interval: 1, easeFactor }
  }

  let newInterval: number
  if (repetitions === 0) newInterval = 1
  else if (repetitions === 1) newInterval = 6
  else newInterval = Math.round(interval * easeFactor)

  const newEase = Math.max(
    1.3,
    easeFactor + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02))
  )

  return {
    repetitions: repetitions + 1,
    interval: newInterval,
    easeFactor: newEase,
  }
}

export async function POST(req: NextRequest) {
  const { card_id, quality } = await req.json()

  if (!card_id || quality === undefined || quality < 0 || quality > 5) {
    return NextResponse.json({ error: 'Invalid input' }, { status: 400 })
  }

  const { data: progress } = await supabase
    .from('user_progress')
    .select('*')
    .eq('card_id', card_id)
    .single()

  const current = progress || { repetitions: 0, ease_factor: 2.5, interval_days: 1 }
  const result = sm2(quality, current.repetitions, current.ease_factor, current.interval_days)

  const nextReview = new Date()
  nextReview.setDate(nextReview.getDate() + result.interval)

  const { error } = await supabase.from('user_progress').upsert({
    card_id,
    user_id: (await supabase.auth.getUser()).data.user?.id,
    ease_factor: result.easeFactor,
    interval_days: result.interval,
    repetitions: result.repetitions,
    next_review_at: nextReview.toISOString(),
    last_reviewed_at: new Date().toISOString(),
  })

  if (error) return NextResponse.json({ error: error.message }, { status: 500 })
  return NextResponse.json({ next_review_at: nextReview.toISOString(), interval: result.interval })
}
```

**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.

### 4. 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.

```
// Paste this prompt into V0's AI chat:
// Create a learn dashboard at app/learn/page.tsx.
// Requirements:
// - Fetch due cards count (where next_review_at <= now) grouped by deck
// - Show streak info in a Card: flame icon, current_streak number, 'day streak' text, longest_streak below
// - Show a grid of deck Cards with: deck title, language Badge, due cards count in a red Badge, total cards, Progress bar for mastered percentage
// - Each deck Card has a 'Review Now' Button linking to /learn/[deckId]
// - If no cards are due, show a celebration message: 'All caught up! Come back tomorrow.'
// - Add a 'Browse Decks' Button linking to /decks for discovering new content
// - 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 nothing
```

> Pro 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.

### 5. 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.

```
// Paste this prompt into V0's AI chat:
// Create a deck browser at app/decks/page.tsx.
// Requirements:
// - Fetch all public decks from Supabase with card count and language name
// - Display as a shadcn/ui Card grid: deck title, language Badge, card count, description preview, creator name
// - Add search Input for filtering by title or language
// - Add a Drawer for deck detail: shows first 5 cards (front only), full description, and a 'Start Learning' Button
// - 'Start Learning' copies the deck to the user's progress and redirects to /learn/[deckId]
// - Use Server Components for the page with Supabase query, client component only for the search input and Drawer interaction
// 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 example

File: `app/api/review/route.ts`

```typescript
import { createClient } from '@supabase/supabase-js'
import { NextRequest, NextResponse } from 'next/server'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

function sm2(
  quality: number,
  repetitions: number,
  easeFactor: number,
  interval: number
) {
  if (quality < 3) {
    return { repetitions: 0, interval: 1, easeFactor }
  }
  let newInterval: number
  if (repetitions === 0) newInterval = 1
  else if (repetitions === 1) newInterval = 6
  else newInterval = Math.round(interval * easeFactor)

  const newEase = Math.max(
    1.3,
    easeFactor + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02))
  )
  return { repetitions: repetitions + 1, interval: newInterval, easeFactor: newEase }
}

export async function POST(req: NextRequest) {
  const { card_id, quality, user_id } = await req.json()

  if (!card_id || quality < 0 || quality > 5) {
    return NextResponse.json({ error: 'Invalid input' }, { status: 400 })
  }

  const { data: progress } = await supabase
    .from('user_progress')
    .select('*')
    .eq('card_id', card_id)
    .eq('user_id', user_id)
    .single()

  const prev = progress || { repetitions: 0, ease_factor: 2.5, interval_days: 1 }
  const result = sm2(quality, prev.repetitions, prev.ease_factor, prev.interval_days)
  const nextReview = new Date()
  nextReview.setDate(nextReview.getDate() + result.interval)

  await supabase.from('user_progress').upsert({
    user_id,
    card_id,
    ease_factor: result.easeFactor,
    interval_days: result.interval,
    repetitions: result.repetitions,
    next_review_at: nextReview.toISOString(),
    last_reviewed_at: new Date().toISOString(),
  })

  return NextResponse.json({ next_review_at: nextReview, interval: result.interval })
}
```

## Common mistakes

- **Running the SM-2 algorithm on the client side** — Users can inspect and modify the JavaScript to manipulate their review schedule, marking all cards as 'Easy' without actually studying them. Fix: 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.
- **Querying all cards instead of only due cards for review** — Fetching the entire deck wastes bandwidth and shows cards that are not due yet, breaking the spaced repetition schedule. Fix: Filter cards with .lte('next_review_at', new Date().toISOString()) to only fetch cards that are due for review today or earlier.
- **Using window or localStorage in the Server Component for streak data** — Server Components run on the server where window and localStorage do not exist. This causes a runtime error during server-side rendering. Fix: 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

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

---

Source: https://www.rapidevelopers.com/how-to-build-v0/language-learning-app
© RapidDev — https://www.rapidevelopers.com/how-to-build-v0/language-learning-app
