# How to Build Online quiz app with V0

- Tool: v0
- Difficulty: Beginner
- Compatibility: V0 Free
- Last updated: April 2026

## TL;DR

Build a timed quiz app with V0 using Next.js, Supabase, and shadcn/ui. Features multiple question types, a countdown timer, server-side answer scoring to prevent cheating, instant results, and a public leaderboard — all in about 30-60 minutes with no Stripe needed.

## Before you start

- A V0 account (free tier works for this project)
- A Supabase project (free tier works — connect via V0's Connect panel)
- Quiz content prepared (questions, answer options, correct answers)
- Basic understanding of forms and state management in React

## Step-by-step guide

### 1. Set up the database schema for quizzes and attempts

Create the Supabase schema for quizzes, questions with multiple types, attempt tracking, and individual answer storage for detailed results.

```
// Paste this prompt into V0's AI chat:
// Build a timed quiz app. Create a Supabase schema:
// 1. quizzes: id (uuid PK), title (text), description (text), time_limit_seconds (int), is_published (boolean DEFAULT false), creator_id (uuid FK to auth.users), created_at (timestamptz)
// 2. questions: id (uuid PK), quiz_id (uuid FK to quizzes), question_text (text), type (text CHECK IN 'multiple_choice','true_false','short_answer'), options (jsonb), correct_answer (text), points (int DEFAULT 10), position (int)
// 3. attempts: id (uuid PK), quiz_id (uuid FK to quizzes), user_id (uuid FK to auth.users), score (int), max_score (int), time_taken_seconds (int), completed_at (timestamptz)
// 4. answers: id (uuid PK), attempt_id (uuid FK to attempts), question_id (uuid FK to questions), user_answer (text), is_correct (boolean), points_earned (int)
// Add RLS so users can only read their own attempts. Generate SQL and types.
```

> Pro tip: Use V0's beginner-friendly workflow — describe the quiz UI in chat, V0 generates it, then use Design Mode (Option+D) to tweak question Card colors and timer styling for free.

**Expected result:** All tables created with proper foreign keys and RLS policies that restrict attempt data to the quiz taker.

### 2. Build the quiz browser and taking interface

Create the quiz catalog showing available quizzes and the quiz-taking interface with a countdown timer, question navigation, and answer selection.

```
// Paste this prompt into V0's AI chat:
// Create quiz pages:
// 1. app/quizzes/page.tsx — browse quizzes with Card grid: title, description, question count, time limit Badge, difficulty Badge. Add search Input and category filter.
// 2. app/quiz/[id]/page.tsx — 'use client' quiz taking interface:
//    - Countdown timer at the top (useEffect with setInterval, auto-submits when timer hits 0)
//    - Progress bar showing question X of Y
//    - Question Card with RadioGroup for multiple choice, true/false toggle, or Input for short answer
//    - Previous/Next navigation Buttons
//    - Submit Button that sends all answers to the scoring API
//    - Store attempt start time in state for server-side time validation
// Use shadcn/ui Card, RadioGroup, Progress, Badge, Button, Input.
```

**Expected result:** The quiz browser shows available quizzes. Clicking one starts the timer and displays questions one at a time with navigation controls.

### 3. Build the server-side scoring API

Create the scoring endpoint that receives answers, fetches correct answers from the database, computes the score server-side, and stores the attempt results. Never expose correct answers to the client.

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

export async function POST(req: NextRequest) {
  const { quiz_id, user_id, answers, start_time } = await req.json()

  const { data: questions } = await supabase
    .from('questions')
    .select('id, correct_answer, points')
    .eq('quiz_id', quiz_id)
    .order('position')

  if (!questions) {
    return NextResponse.json({ error: 'Quiz not found' }, { status: 404 })
  }

  const { data: quiz } = await supabase
    .from('quizzes')
    .select('time_limit_seconds')
    .eq('id', quiz_id)
    .single()

  const timeTaken = Math.floor((Date.now() - new Date(start_time).getTime()) / 1000)
  if (quiz && timeTaken > quiz.time_limit_seconds + 5) {
    return NextResponse.json({ error: 'Time limit exceeded' }, { status: 400 })
  }

  let score = 0
  const maxScore = questions.reduce((sum, q) => sum + q.points, 0)
  const gradedAnswers = questions.map((q) => {
    const userAnswer = answers[q.id] || ''
    const isCorrect = userAnswer.toLowerCase() === q.correct_answer.toLowerCase()
    const pointsEarned = isCorrect ? q.points : 0
    score += pointsEarned
    return {
      question_id: q.id,
      user_answer: userAnswer,
      is_correct: isCorrect,
      points_earned: pointsEarned,
    }
  })

  const { data: attempt } = await supabase
    .from('attempts')
    .insert({ quiz_id, user_id, score, max_score: maxScore, time_taken_seconds: timeTaken })
    .select('id')
    .single()

  if (attempt) {
    await supabase.from('answers').insert(
      gradedAnswers.map((a) => ({ ...a, attempt_id: attempt.id }))
    )
  }

  return NextResponse.json({ attempt_id: attempt?.id, score, max_score: maxScore, time_taken: timeTaken })
}
```

**Expected result:** Submitting answers sends them to the server where scoring happens. The client receives only the final score — correct answers are never exposed.

### 4. Build the results page, leaderboard, and deploy

Create the results breakdown page and a public leaderboard showing top scores for each quiz. Then deploy.

```
// Paste this prompt into V0's AI chat:
// Create results and leaderboard pages:
// 1. app/quiz/[id]/results/page.tsx — score Card showing score/maxScore percentage, time taken, and a Table of each question with the user's answer, correct answer, and a green checkmark or red X icon.
// 2. app/quiz/[id]/leaderboard/page.tsx — Table showing rank, user Avatar and name, score Badge, and time taken. Sorted by score DESC then time ASC. Highlight the current user's row.
// 3. app/create/page.tsx — quiz builder form: Input for title, Textarea for description, Input for time limit. Add Question Button that appends a question form with question_text Input, type Select (multiple_choice/true_false/short_answer), dynamic option Inputs, and correct_answer Select. Reorder with drag handles.
// Use shadcn/ui Table, Card, Badge, Avatar, Select, Button, Input, Textarea.
```

> Pro tip: This is a Supabase-only project with no Stripe needed — set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY in Vars and you are ready to deploy.

**Expected result:** Results show a detailed breakdown per question. The leaderboard ranks all participants. The quiz builder lets creators add new quizzes. The app is deployed.

## Complete code example

File: `app/api/quiz/submit/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!
)

export async function POST(req: NextRequest) {
  const { quiz_id, user_id, answers, start_time } = await req.json()

  const { data: questions } = await supabase
    .from('questions')
    .select('id, correct_answer, points')
    .eq('quiz_id', quiz_id)
    .order('position')

  if (!questions?.length) {
    return NextResponse.json({ error: 'Quiz not found' }, { status: 404 })
  }

  const { data: quiz } = await supabase
    .from('quizzes')
    .select('time_limit_seconds')
    .eq('id', quiz_id)
    .single()

  const elapsed = Math.floor(
    (Date.now() - new Date(start_time).getTime()) / 1000
  )

  if (quiz && elapsed > quiz.time_limit_seconds + 5) {
    return NextResponse.json(
      { error: 'Time limit exceeded' },
      { status: 400 }
    )
  }

  let score = 0
  const maxScore = questions.reduce((s, q) => s + q.points, 0)

  const graded = questions.map((q) => {
    const ua = answers[q.id] || ''
    const correct =
      ua.toLowerCase().trim() === q.correct_answer.toLowerCase().trim()
    const pts = correct ? q.points : 0
    score += pts
    return {
      question_id: q.id,
      user_answer: ua,
      is_correct: correct,
      points_earned: pts,
    }
  })

  const { data: attempt } = await supabase
    .from('attempts')
    .insert({
      quiz_id,
      user_id,
      score,
      max_score: maxScore,
      time_taken_seconds: elapsed,
    })
    .select('id')
    .single()

  if (attempt) {
    await supabase
      .from('answers')
      .insert(graded.map((a) => ({ ...a, attempt_id: attempt.id })))
  }

  return NextResponse.json({
    attempt_id: attempt?.id,
    score,
    max_score: maxScore,
    time_taken: elapsed,
  })
}
```

## Common mistakes

- **Scoring quizzes on the client side** — Users can open browser DevTools and find correct answers in the JavaScript bundle or network responses before submitting. Fix: Score answers in an API route or Server Action. The client sends answer IDs; the server fetches correct answers from the database, computes the score, and stores results.
- **Not validating the timer server-side** — Users can modify the countdown timer in the browser to give themselves unlimited time, defeating the purpose of timed quizzes. Fix: Store the attempt start time in the database and compare it to submission time on the server. Add a 5-second grace period for network latency, then reject late submissions.
- **Using offset-based pagination for the leaderboard** — As the number of attempts grows, offset pagination becomes slow because the database counts through all skipped rows. Fix: Use a composite index on (quiz_id, score DESC, time_taken_seconds ASC) and cursor-based pagination keyed on score and time for fast leaderboard queries.

## Best practices

- Score quizzes server-side in an API route — never expose correct answers to the client
- Validate the time limit server-side by comparing attempt start time with submission time
- Use V0's Design Mode (Option+D) to style quiz Cards and timer components without spending credits
- Store question options and correct answers in a JSONB field for flexible question types
- Add a unique constraint on (quiz_id, user_id) in attempts if you want to limit to one attempt per user
- Use RLS policies so users can only read their own attempt details but can see aggregate leaderboard scores
- Randomize question order per attempt to reduce answer-sharing between users
- Set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY in Vars — no Stripe needed for this project

## Frequently asked questions

### How does the anti-cheat scoring work?

When a user submits their answers, the client sends only the answer selections (not correct answers). The server fetches correct answers from the database, compares them, computes the score, and returns results. Correct answers are never included in the client bundle or API responses.

### Can I add different question types?

Yes. The questions table has a type field supporting multiple_choice, true_false, and short_answer. The quiz-taking component renders RadioGroup for multiple choice, a toggle for true/false, or an Input for short answer. Add new types by extending the type check and adding corresponding UI components.

### How is the timer enforced?

The timer is enforced on both client and server. The client shows a countdown and auto-submits at zero. The server compares the attempt start time with the submission timestamp and rejects submissions that exceed the time limit plus a 5-second grace period.

### Do I need a paid V0 plan?

No. The quiz app is simple enough to build with the V0 free tier. It requires only a few prompts for the quiz interface, scoring API, and leaderboard. No Stripe integration is needed.

### How does the leaderboard work?

The leaderboard queries attempts for a specific quiz, sorted by score descending and time ascending (faster completions rank higher at the same score). It displays rank, user name, score, and time taken in a shadcn/ui Table.

### How do I deploy the quiz app?

Click Share in V0, then Publish to Production. Set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY in the Vars tab. No Stripe or other external services needed.

### Can RapidDev help build a custom quiz platform?

Yes. RapidDev has built over 600 apps including assessment platforms with proctoring, adaptive difficulty, and analytics dashboards. Book a free consultation to discuss your quiz or testing needs.

---

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