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.
What you're building
The critical design decision in any quiz app is where scoring happens. If the correct answers are loaded in the browser (even in JavaScript), a motivated user can find them in DevTools before answering. Server-side scoring moves the correct answer comparison to a Deno Edge Function that the frontend never sees.
The questions table stores the correct answer index, but the RLS policy blocks SELECT on the correct_answer column for non-admins. The frontend only receives the question text and the answer options. When the quiz is submitted (or the timer expires), the frontend sends the user's answers to the scoring Edge Function. The Edge Function fetches correct answers using the service role key, grades each answer, calculates the total score, and stores the result in quiz_attempts.
The countdown timer is a React useEffect with setInterval. Every second it decrements a state variable. When it hits zero, it triggers the same submit function as the Submit button — so auto-submission and manual submission share the same code path. The leaderboard is a simple ORDER BY score DESC, time_taken_seconds ASC view that rewards both accuracy and speed.
Final result
A timed quiz app with server-side grading, auto-submit, per-question feedback, and a speed-weighted leaderboard.
Tech stack
Prerequisites
- 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)
Build steps
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.
1Create a quiz app with Supabase. Set up these tables:23- 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_at4- 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_at5- 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)67RLS:8- quizzes: anon/authenticated SELECT where is_published=true, admin INSERT/UPDATE/DELETE9- 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)10- quiz_attempts: students INSERT/SELECT their own rows, admin SELECT all1112Create a view questions_public that selects all columns EXCEPT correct_answer and explanation from questions. Grant SELECT on this view to authenticated users.1314Create 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.
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.
1Build a quiz page at src/pages/TakeQuiz.tsx. Route: /quiz/:quizId.23On mount:41. Fetch quiz metadata (title, time_limit_seconds, question count)52. Check if the student already has a quiz_attempt for this quiz. If so, redirect to /quiz/:quizId/results63. Fetch questions from questions_public view ordered by sort_order74. Set a startedAt = Date.now() in a ref (not state — prevents re-renders)85. Initialize answers as an array of null values, one per question910Timer logic:11- Store remaining seconds in state, initialized from quiz.time_limit_seconds12- useEffect with setInterval every 1000ms: setRemaining(r => r - 1)13- When remaining reaches 0: clear the interval, call submitQuiz() with whatever answers are filled14- Show remaining time as MM:SS format. Turn red when < 60 seconds. Show a pulsing Badge when < 30 seconds.15- Do NOT reset or pause the timer when the user navigates between questions1617Question display:18- Show one question at a time (current question index in state)19- Question text in a large Card20- RadioGroup with one RadioGroupItem per option (from the options JSON array)21- 'Previous' and 'Next' Buttons. On last question, show 'Submit' Button.22- Progress dots at the top showing answered (filled) vs unanswered (empty) for each question2324submitQuiz function:25- Call supabase.functions.invoke('score-quiz', { body: { quizId, answers, startedAt } })26- On success, navigate to /quiz/:quizId/results27- Disable the submit button and show a loading spinner while scoringPro 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.
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.
1// supabase/functions/score-quiz/index.ts2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'45const cors = {6 'Access-Control-Allow-Origin': '*',7 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',8 'Content-Type': 'application/json',9}1011serve(async (req: Request) => {12 if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })1314 const authHeader = req.headers.get('Authorization')15 if (!authHeader) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: cors })1617 const { quizId, answers, startedAt } = await req.json()1819 const userClient = createClient(20 Deno.env.get('SUPABASE_URL') ?? '',21 Deno.env.get('SUPABASE_ANON_KEY') ?? '',22 { global: { headers: { Authorization: authHeader } } }23 )24 const { data: { user } } = await userClient.auth.getUser()25 if (!user) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: cors })2627 const supabase = createClient(28 Deno.env.get('SUPABASE_URL') ?? '',29 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''30 )3132 const { data: questions } = await supabase33 .from('questions')34 .select('id, correct_answer, explanation, sort_order')35 .eq('quiz_id', quizId)36 .order('sort_order')3738 if (!questions) return new Response(JSON.stringify({ error: 'Quiz not found' }), { status: 404, headers: cors })3940 const { data: quiz } = await supabase41 .from('quizzes')42 .select('passing_score, time_limit_seconds')43 .eq('id', quizId)44 .single()4546 const results = questions.map((q, i) => ({47 questionId: q.id,48 submittedAnswer: answers[i] ?? null,49 correct: answers[i] === q.correct_answer,50 correctAnswer: q.correct_answer,51 explanation: q.explanation,52 }))5354 const correctCount = results.filter((r) => r.correct).length55 const score = Math.round((correctCount / questions.length) * 100)56 const timeTaken = Math.min(Math.round((Date.now() - startedAt) / 1000), quiz?.time_limit_seconds ?? 999)57 const passed = score >= (quiz?.passing_score ?? 70)5859 await supabase.from('quiz_attempts').insert({60 quiz_id: quizId,61 student_id: user.id,62 answers,63 score,64 passed,65 time_taken_seconds: timeTaken,66 submitted_at: new Date().toISOString(),67 })6869 return new Response(JSON.stringify({ score, passed, timeTaken, results }), { headers: cors })70})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.
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.
1Build two views:231. src/pages/QuizResults.tsx — shown after quiz submission:4- Fetch the student's quiz_attempt for this quiz5- If no attempt found (came directly to this URL), redirect to /quiz/:quizId6- Show a large score Badge: green if passed, red if failed7- Show: 'You scored X% — You Passed!' or 'You scored X% — Better luck next time'8- Show time_taken_seconds formatted as 'Completed in 3m 42s'9- Show a list of results per question:10 - Question text11 - The answer the student chose (highlighted green if correct, red if incorrect)12 - The correct answer (shown regardless)13 - Explanation text in a muted paragraph below14- Add a 'View Leaderboard' Button that shows a Sheet with the leaderboard15162. Leaderboard Sheet:17- Fetch top 10 quiz_attempts for this quiz ordered by score DESC, time_taken_seconds ASC18- Join with auth.users to get display names (or user_profiles if that table exists)19- Render as a Table with columns: Rank (1st Badge gold, 2nd silver, 3rd bronze), Name, Score (%), Time20- Highlight the current student's row with a different backgroundExpected 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
1import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'2import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'34const cors = {5 'Access-Control-Allow-Origin': '*',6 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',7 'Content-Type': 'application/json',8}910serve(async (req: Request) => {11 if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })1213 const authHeader = req.headers.get('Authorization')14 if (!authHeader) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: cors })1516 const { quizId, answers, startedAt } = await req.json()1718 const userClient = createClient(19 Deno.env.get('SUPABASE_URL') ?? '',20 Deno.env.get('SUPABASE_ANON_KEY') ?? '',21 { global: { headers: { Authorization: authHeader } } }22 )23 const { data: { user } } = await userClient.auth.getUser()24 if (!user) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: cors })2526 const supabase = createClient(27 Deno.env.get('SUPABASE_URL') ?? '',28 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''29 )3031 const { data: questions } = await supabase32 .from('questions')33 .select('id, correct_answer, explanation, sort_order')34 .eq('quiz_id', quizId)35 .order('sort_order')3637 if (!questions || questions.length === 0) {38 return new Response(JSON.stringify({ error: 'Quiz not found' }), { status: 404, headers: cors })39 }4041 const { data: quiz } = await supabase42 .from('quizzes')43 .select('passing_score, time_limit_seconds')44 .eq('id', quizId)45 .single()4647 const results = questions.map((q, i) => ({48 questionId: q.id,49 submittedAnswer: answers[i] ?? null,50 correct: answers[i] === q.correct_answer,51 correctAnswer: q.correct_answer,52 explanation: q.explanation,53 }))5455 const correctCount = results.filter((r) => r.correct).length56 const score = Math.round((correctCount / questions.length) * 100)57 const timeTaken = Math.min(58 Math.round((Date.now() - startedAt) / 1000),59 quiz?.time_limit_seconds ?? 999960 )61 const passed = score >= (quiz?.passing_score ?? 70)6263 const { error: insertError } = await supabase.from('quiz_attempts').insert({64 quiz_id: quizId,65 student_id: user.id,66 answers,67 score,68 passed,69 time_taken_seconds: timeTaken,70 submitted_at: new Date().toISOString(),71 })7273 if (insertError) {74 return new Response(JSON.stringify({ error: insertError.message }), { status: 500, headers: cors })75 }7677 return new Response(JSON.stringify({ score, passed, timeTaken, results }), { headers: cors })78})Customization ideas
Retake attempts with attempt number tracking
Remove the UNIQUE constraint on (quiz_id, student_id) in quiz_attempts and add an attempt_number column. Allow students to retake quizzes with a 'Retake Quiz' button on the results page. The leaderboard shows the best score per student across all their attempts.
Admin quiz builder UI
Build an admin page where instructors create quizzes visually. Each question is a Card with a question text Input, four option Inputs (marked with radio buttons to select which is correct), and an explanation Textarea. Questions can be added, removed, and reordered with up/down arrows.
Question randomization
Add a randomize_questions boolean to the quizzes table. When true, the quiz-taking page shuffles the question order and option order before display. Store the shuffled order in the quiz session (in state) so the scoring function can map back to the correct answer indices correctly.
Embed quiz in an LMS course
Connect this quiz app to the online-education-platform guide. Add a lesson_type column to the lessons table: 'video' or 'quiz'. When lesson_type is 'quiz', link to a quiz_id. The lesson player renders the quiz component instead of the video player. Passing the quiz sets lesson_progress.completed_at.
Common pitfalls
Pitfall: Loading correct answers in the frontend from the questions table
How to avoid: 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.
Pitfall: Using setInterval without clearing it on component unmount
How to avoid: 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.
Pitfall: Not capping time_taken_seconds at the quiz time limit in the Edge Function
How to avoid: 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.
Pitfall: Allowing multiple quiz_attempt rows per student per quiz without business logic
How to avoid: 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.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a quiz app with server-side scoring. My Edge Function receives an array of submitted answer indices and grades them against the correct answers from the database. I want to add a partial-credit scoring mode where some questions are worth more points than others. My questions table has a points column (default 1). Show me how to modify the grading logic to use weighted scores and calculate a percentage based on total possible points instead of question count.
Add an admin quiz analytics page for each quiz. Show: 1) Average score across all attempts (large number). 2) Pass rate percentage. 3) Average time taken. 4) A bar chart (Recharts) showing score distribution in buckets: 0-20%, 21-40%, 41-60%, 61-80%, 81-100%. 5) A table of the hardest questions sorted by incorrect answer rate — show question text and the percentage of students who got it wrong. All data from quiz_attempts and questions tables.
In Supabase, create a view quiz_leaderboard that accepts a quiz_id parameter and returns the top 10 distinct student scores for that quiz. Each row: rank (ROW_NUMBER() OVER), student_id, display_name (from user_profiles or auth.users email), score (best attempt), time_taken_seconds (from the attempt with the best score), submitted_at. Use a subquery to pick the best attempt per student before ranking. Write the SQL.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation