# How to Build an Online Quiz App with Lovable

- Tool: How to Build with Lovable
- Difficulty: Beginner
- Compatibility: Lovable Free or higher
- Last updated: April 2026

## TL;DR

Build timed quizzes in Lovable with server-side scoring via a Supabase Edge Function so answers cannot be inspected in the browser. A countdown timer auto-submits on expiry. RadioGroup answers give a clean one-choice-per-question UX. A leaderboard view shows top scores per quiz — all in about 90 minutes.

## Before you start

- Lovable Free account or higher
- Supabase project with URL and anon key saved to Cloud tab → Secrets
- Service role key saved to Cloud tab → Secrets as SUPABASE_SERVICE_ROLE_KEY
- Supabase Auth configured (email/password is fine for quiz takers)

## Step-by-step guide

### 1. Create the quiz schema with answer RLS

The key schema detail is that the correct_answer column must be hidden from non-admins via RLS. The questions table stores answer options as a JSON array, and correct_answer is an index into that array.

```
Create a quiz app with Supabase. Set up these tables:

- quizzes: id (uuid pk), title (text not null), description (text), time_limit_seconds (int not null, e.g. 300 for 5 minutes), passing_score (int default 70, percentage), is_published (bool default false), created_by (uuid references auth.users), created_at
- questions: id (uuid pk), quiz_id (uuid references quizzes on delete cascade), question_text (text not null), options (jsonb not null, array of strings, e.g. ["Paris", "London", "Berlin"]), correct_answer (int not null, 0-based index into options array), explanation (text, shown after submission), sort_order (int not null), created_at
- quiz_attempts: id (uuid pk), quiz_id (uuid references quizzes), student_id (uuid references auth.users), answers (jsonb, array of int indices submitted by the student), score (int, percentage 0-100), passed (bool), time_taken_seconds (int), submitted_at, UNIQUE(quiz_id, student_id)

RLS:
- quizzes: anon/authenticated SELECT where is_published=true, admin INSERT/UPDATE/DELETE
- questions: authenticated SELECT for question_text, options, sort_order, quiz_id only — NOT correct_answer or explanation (use column-level security or a view that excludes these columns)
- quiz_attempts: students INSERT/SELECT their own rows, admin SELECT all

Create a view questions_public that selects all columns EXCEPT correct_answer and explanation from questions. Grant SELECT on this view to authenticated users.

Create an index on quiz_attempts(quiz_id, score DESC, time_taken_seconds ASC) for the leaderboard query.
```

> Pro tip: Ask Lovable to create the questions_public view immediately: CREATE VIEW questions_public AS SELECT id, quiz_id, question_text, options, sort_order FROM questions. Then grant SELECT: GRANT SELECT ON questions_public TO authenticated. All frontend queries use questions_public, never questions directly.

**Expected result:** Tables are created. The questions_public view hides correct_answer and explanation. TypeScript types are generated for the view and all tables.

### 2. Build the timed quiz UI with auto-submit

Create the quiz-taking page. The countdown timer is the core interactive element. It must be reliable across tab switches and must auto-submit exactly once when it expires.

```
Build a quiz page at src/pages/TakeQuiz.tsx. Route: /quiz/:quizId.

On mount:
1. Fetch quiz metadata (title, time_limit_seconds, question count)
2. Check if the student already has a quiz_attempt for this quiz. If so, redirect to /quiz/:quizId/results
3. Fetch questions from questions_public view ordered by sort_order
4. Set a startedAt = Date.now() in a ref (not state — prevents re-renders)
5. Initialize answers as an array of null values, one per question

Timer logic:
- Store remaining seconds in state, initialized from quiz.time_limit_seconds
- useEffect with setInterval every 1000ms: setRemaining(r => r - 1)
- When remaining reaches 0: clear the interval, call submitQuiz() with whatever answers are filled
- Show remaining time as MM:SS format. Turn red when < 60 seconds. Show a pulsing Badge when < 30 seconds.
- Do NOT reset or pause the timer when the user navigates between questions

Question display:
- Show one question at a time (current question index in state)
- Question text in a large Card
- RadioGroup with one RadioGroupItem per option (from the options JSON array)
- 'Previous' and 'Next' Buttons. On last question, show 'Submit' Button.
- Progress dots at the top showing answered (filled) vs unanswered (empty) for each question

submitQuiz function:
- Call supabase.functions.invoke('score-quiz', { body: { quizId, answers, startedAt } })
- On success, navigate to /quiz/:quizId/results
- Disable the submit button and show a loading spinner while scoring
```

> Pro tip: Use a ref for startedAt instead of state so it never changes across re-renders. If you use state, React might re-initialize it on a re-render and your time_taken_seconds will be wrong.

**Expected result:** The quiz timer counts down and turns red near expiry. Selecting RadioGroup options records the answers. Auto-submit fires when time hits zero. Manual submit navigates to results.

### 3. Build the server-side scoring Edge Function

The scoring Edge Function is where correct answers are compared. It fetches the answers using the service role key (bypassing the view that hides them), grades each question, stores the result, and returns per-question feedback.

```
// supabase/functions/score-quiz/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const cors = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
  'Content-Type': 'application/json',
}

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })

  const authHeader = req.headers.get('Authorization')
  if (!authHeader) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: cors })

  const { quizId, answers, startedAt } = await req.json()

  const userClient = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_ANON_KEY') ?? '',
    { global: { headers: { Authorization: authHeader } } }
  )
  const { data: { user } } = await userClient.auth.getUser()
  if (!user) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: cors })

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
  )

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

  if (!questions) return new Response(JSON.stringify({ error: 'Quiz not found' }), { status: 404, headers: cors })

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

  const results = questions.map((q, i) => ({
    questionId: q.id,
    submittedAnswer: answers[i] ?? null,
    correct: answers[i] === q.correct_answer,
    correctAnswer: q.correct_answer,
    explanation: q.explanation,
  }))

  const correctCount = results.filter((r) => r.correct).length
  const score = Math.round((correctCount / questions.length) * 100)
  const timeTaken = Math.min(Math.round((Date.now() - startedAt) / 1000), quiz?.time_limit_seconds ?? 999)
  const passed = score >= (quiz?.passing_score ?? 70)

  await supabase.from('quiz_attempts').insert({
    quiz_id: quizId,
    student_id: user.id,
    answers,
    score,
    passed,
    time_taken_seconds: timeTaken,
    submitted_at: new Date().toISOString(),
  })

  return new Response(JSON.stringify({ score, passed, timeTaken, results }), { headers: cors })
})
```

**Expected result:** The Edge Function fetches correct answers with the service role key, computes the score, stores the attempt, and returns per-question feedback. The frontend never sees correct answers directly.

### 4. Build the results page and leaderboard

Create the post-quiz results page and a leaderboard that ranks all attempts for a quiz by score and speed.

```
Build two views:

1. src/pages/QuizResults.tsx — shown after quiz submission:
- Fetch the student's quiz_attempt for this quiz
- If no attempt found (came directly to this URL), redirect to /quiz/:quizId
- Show a large score Badge: green if passed, red if failed
- Show: 'You scored X% — You Passed!' or 'You scored X% — Better luck next time'
- Show time_taken_seconds formatted as 'Completed in 3m 42s'
- Show a list of results per question:
  - Question text
  - The answer the student chose (highlighted green if correct, red if incorrect)
  - The correct answer (shown regardless)
  - Explanation text in a muted paragraph below
- Add a 'View Leaderboard' Button that shows a Sheet with the leaderboard

2. Leaderboard Sheet:
- Fetch top 10 quiz_attempts for this quiz ordered by score DESC, time_taken_seconds ASC
- Join with auth.users to get display names (or user_profiles if that table exists)
- Render as a Table with columns: Rank (1st Badge gold, 2nd silver, 3rd bronze), Name, Score (%), Time
- Highlight the current student's row with a different background
```

**Expected result:** The results page shows score, pass/fail, and per-question feedback with explanations. The leaderboard Sheet ranks all attempts with the current student highlighted.

## Complete code example

File: `supabase/functions/score-quiz/index.ts`

```typescript
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const cors = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
  'Content-Type': 'application/json',
}

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })

  const authHeader = req.headers.get('Authorization')
  if (!authHeader) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: cors })

  const { quizId, answers, startedAt } = await req.json()

  const userClient = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_ANON_KEY') ?? '',
    { global: { headers: { Authorization: authHeader } } }
  )
  const { data: { user } } = await userClient.auth.getUser()
  if (!user) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: cors })

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
  )

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

  if (!questions || questions.length === 0) {
    return new Response(JSON.stringify({ error: 'Quiz not found' }), { status: 404, headers: cors })
  }

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

  const results = questions.map((q, i) => ({
    questionId: q.id,
    submittedAnswer: answers[i] ?? null,
    correct: answers[i] === q.correct_answer,
    correctAnswer: q.correct_answer,
    explanation: q.explanation,
  }))

  const correctCount = results.filter((r) => r.correct).length
  const score = Math.round((correctCount / questions.length) * 100)
  const timeTaken = Math.min(
    Math.round((Date.now() - startedAt) / 1000),
    quiz?.time_limit_seconds ?? 9999
  )
  const passed = score >= (quiz?.passing_score ?? 70)

  const { error: insertError } = await supabase.from('quiz_attempts').insert({
    quiz_id: quizId,
    student_id: user.id,
    answers,
    score,
    passed,
    time_taken_seconds: timeTaken,
    submitted_at: new Date().toISOString(),
  })

  if (insertError) {
    return new Response(JSON.stringify({ error: insertError.message }), { status: 500, headers: cors })
  }

  return new Response(JSON.stringify({ score, passed, timeTaken, results }), { headers: cors })
})
```

## Common mistakes

- **Loading correct answers in the frontend from the questions table** — Even if you hide the answers in the UI, they are visible in browser DevTools Network tab. A user can open DevTools, inspect the Supabase query response, and see all correct answers before answering. Fix: Use the questions_public view for all frontend queries. This view excludes correct_answer and explanation. Only the scoring Edge Function fetches from the questions table directly using the service role key.
- **Using setInterval without clearing it on component unmount** — If the component unmounts (navigation or React StrictMode double-mount in development), the interval keeps running in the background, causing memory leaks and potential double-submission. Fix: Always return a cleanup function from the useEffect that runs the timer: return () => clearInterval(intervalId). Also keep a submitted ref flag to prevent the auto-submit from firing twice if the component re-renders at exactly zero.
- **Not capping time_taken_seconds at the quiz time limit in the Edge Function** — If startedAt is manipulated (or the user has a clock difference), the calculated time_taken could be negative or larger than the time limit, distorting leaderboard rankings. Fix: In the Edge Function, cap time_taken: Math.min(Math.round((Date.now() - startedAt) / 1000), quiz.time_limit_seconds). Discard attempts where the calculated time is negative.
- **Allowing multiple quiz_attempt rows per student per quiz without business logic** — If the UNIQUE constraint is removed without adding attempt tracking logic, a student who refreshes the results page might accidentally submit a second empty attempt. Fix: Keep the UNIQUE(quiz_id, student_id) constraint unless you intentionally support retakes. If you support retakes, check for an in-progress attempt before starting a new one and confirm with an AlertDialog.

## Best practices

- Score on the server, always. Never trust client-computed scores. The Edge Function is the single source of truth for quiz results.
- Store startedAt as a timestamp sent from the frontend but cap the result server-side. This balance gives you accurate timing without trusting the client.
- Show the explanation for wrong answers immediately on the results page. Learners retain information better when feedback is immediate — this is the core value of a quiz over passive reading.
- Add a UNIQUE(quiz_id, student_id) constraint to quiz_attempts to prevent duplicate submissions. Handle the unique violation gracefully by redirecting to the existing results page.
- Disable all navigation away from the quiz page while a quiz is active. Show a browser confirmation dialog via the beforeunload event and add a React Router prompt to prevent accidental navigation loss.
- For the leaderboard, rank by score DESC then time_taken_seconds ASC. Two students with the same score are ranked by who finished faster — this incentivizes both accuracy and speed.

## Frequently asked questions

### Can students retake a quiz?

Not by default — the UNIQUE(quiz_id, student_id) constraint prevents duplicate attempts. To enable retakes, remove that constraint, add an attempt_number column, and update the RLS to allow multiple rows per (student_id, quiz_id). The leaderboard query should then use MAX(score) per student rather than a direct lookup.

### How do I prevent cheating by opening the quiz in multiple browser tabs?

Use localStorage or sessionStorage to set a quiz_in_progress flag when a quiz starts. On the quiz start page, check this flag. If it is set and the attempt does not exist in the database yet, warn the user. For stronger protection, use a Supabase Realtime presence channel where each active quiz attempt sends a heartbeat — a second tab joining the same presence slot can trigger a warning.

### What happens if the Edge Function call fails during auto-submit?

Add a retry mechanism. If supabase.functions.invoke fails, wait 2 seconds and retry up to 3 times. Store the answers in sessionStorage before submitting so they are not lost on a browser refresh. If all retries fail, show an error message: 'Submission failed — your answers are saved. Click to retry.' with a manual retry button.

### How is the correct answer stored in the database?

The correct_answer column stores a zero-based integer index into the options array. If options is ['Paris', 'London', 'Berlin', 'Rome'] and the correct answer is 'Paris', correct_answer is 0. The frontend sends the selected option index (not the text) in the answers array. This makes scoring a simple index comparison in the Edge Function.

### Can I add images or code snippets to questions?

Yes. Store question_text as markdown and render it using a markdown parser (react-markdown) in the quiz UI. Code snippets in markdown fenced blocks render with syntax highlighting. For images, include markdown image syntax pointing to Supabase Storage public URLs.

### How do I build the admin interface for creating questions?

Ask Lovable to build an admin quiz builder page with a form that adds questions one by one. Each question form has: question text Textarea, four option Inputs in a list, a radio button to mark which option is correct, an explanation Textarea, and a points Input. Questions are saved to the questions table directly from the admin session using the authenticated RLS policy.

### Is there help available if I want to add adaptive difficulty or spaced repetition?

RapidDev builds advanced quiz and learning apps on Lovable including adaptive difficulty (increasing question difficulty based on past performance) and spaced repetition scheduling. Reach out if your quiz needs more sophisticated learning mechanics.

---

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