Back to Blog
Tutorial

Vue.js Quiz Widget with QuizAPI

Build a reusable quiz widget using Vue 3 Composition API that fetches questions from QuizAPI and tracks scores with reactive state.

Bobby Iliev2026-04-088 min read
Share:

A Quiz Widget You Can Drop Anywhere

A self-contained quiz widget is one of the most useful components you can build. Embed it in a blog post, a documentation page, or a learning platform - it works the same everywhere. Vue 3's Composition API makes this clean to implement because you can extract all quiz logic into a composable and keep the template focused on rendering.

In this tutorial, you will build a <QuizWidget> component that fetches questions from QuizAPI, manages answer state reactively, and displays a final score.

Prerequisites

  • Vue 3.4+ with Composition API
  • Basic TypeScript knowledge
  • A QuizAPI API key

Project Setup

If you are adding this to an existing Vue project, skip ahead. For a fresh project:

npm create vue@latest quiz-widget -- --typescript cd quiz-widget npm install

Add your API key to .env:

VITE_QUIZAPI_KEY=your_api_key_here VITE_QUIZAPI_URL=https://quizapi.io/api/v1

Types and API Layer

Create src/types/quiz.ts:

1export interface Answer { 2 id: string; 3 text: string; 4 isCorrect?: boolean; 5} 6 7export interface Question { 8 id: string; 9 text: string; 10 type: "multiple_choice" | "true_false"; 11 answers: Answer[]; 12 explanation: string | null; 13} 14 15export interface Quiz { 16 id: string; 17 title: string; 18 description: string | null; 19 questions: Question[]; 20} 21 22export interface QuizResult { 23 score: number; 24 total: number; 25 details: Array<{ 26 questionId: string; 27 correct: boolean; 28 explanation: string | null; 29 }>; 30}

Create the API client at src/api/quizapi.ts:

1import type { Quiz, QuizResult } from "@/types/quiz"; 2 3const BASE_URL = import.meta.env.VITE_QUIZAPI_URL; 4const API_KEY = import.meta.env.VITE_QUIZAPI_KEY; 5 6async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> { 7 const res = await fetch(`${BASE_URL}${path}`, { 8 ...options, 9 headers: { 10 Authorization: `Bearer ${API_KEY}`, 11 "Content-Type": "application/json", 12 ...options?.headers, 13 }, 14 }); 15 16 if (!res.ok) { 17 throw new Error(`API error: ${res.status} ${res.statusText}`); 18 } 19 20 return res.json(); 21} 22 23export function fetchQuiz(quizId: string): Promise<Quiz> { 24 return apiFetch<Quiz>(`/quizzes/${quizId}`); 25} 26 27export function submitQuiz( 28 quizId: string, 29 answers: Record<string, string> 30): Promise<QuizResult> { 31 return apiFetch<QuizResult>(`/quizzes/${quizId}/submit`, { 32 method: "POST", 33 body: JSON.stringify({ answers }), 34 }); 35}

The Quiz Composable

The composable is where all the quiz logic lives. Create src/composables/useQuiz.ts:

1import { ref, computed, readonly } from "vue"; 2import type { Quiz, Question, QuizResult } from "@/types/quiz"; 3import { fetchQuiz, submitQuiz } from "@/api/quizapi"; 4 5export function useQuiz(quizId: string) { 6 const quiz = ref<Quiz | null>(null); 7 const answers = ref<Record<string, string>>({}); 8 const currentIndex = ref(0); 9 const loading = ref(false); 10 const error = ref<string | null>(null); 11 const result = ref<QuizResult | null>(null); 12 const submitting = ref(false); 13 14 const currentQuestion = computed<Question | null>(() => { 15 if (!quiz.value) return null; 16 return quiz.value.questions[currentIndex.value] ?? null; 17 }); 18 19 const totalQuestions = computed(() => quiz.value?.questions.length ?? 0); 20 21 const progress = computed(() => { 22 if (totalQuestions.value === 0) return 0; 23 return Math.round( 24 (Object.keys(answers.value).length / totalQuestions.value) * 100 25 ); 26 }); 27 28 const isComplete = computed(() => { 29 return Object.keys(answers.value).length === totalQuestions.value; 30 }); 31 32 const isFinished = computed(() => result.value !== null); 33 34 async function load() { 35 loading.value = true; 36 error.value = null; 37 38 try { 39 quiz.value = await fetchQuiz(quizId); 40 } catch (err) { 41 error.value = err instanceof Error ? err.message : "Failed to load quiz"; 42 } finally { 43 loading.value = false; 44 } 45 } 46 47 function selectAnswer(questionId: string, answerId: string) { 48 answers.value = { ...answers.value, [questionId]: answerId }; 49 } 50 51 function next() { 52 if (currentIndex.value < totalQuestions.value - 1) { 53 currentIndex.value++; 54 } 55 } 56 57 function previous() { 58 if (currentIndex.value > 0) { 59 currentIndex.value--; 60 } 61 } 62 63 function goTo(index: number) { 64 if (index >= 0 && index < totalQuestions.value) { 65 currentIndex.value = index; 66 } 67 } 68 69 async function submit() { 70 submitting.value = true; 71 error.value = null; 72 73 try { 74 result.value = await submitQuiz(quizId, answers.value); 75 } catch (err) { 76 error.value = 77 err instanceof Error ? err.message : "Failed to submit quiz"; 78 } finally { 79 submitting.value = false; 80 } 81 } 82 83 function reset() { 84 answers.value = {}; 85 currentIndex.value = 0; 86 result.value = null; 87 } 88 89 return { 90 quiz: readonly(quiz), 91 currentQuestion, 92 currentIndex: readonly(currentIndex), 93 totalQuestions, 94 progress, 95 isComplete, 96 isFinished, 97 loading: readonly(loading), 98 submitting: readonly(submitting), 99 error: readonly(error), 100 result: readonly(result), 101 answers: readonly(answers), 102 load, 103 selectAnswer, 104 next, 105 previous, 106 goTo, 107 submit, 108 reset, 109 }; 110}

Using readonly wrappers on the returned refs prevents the template from accidentally mutating state directly. All state changes go through the composable functions.

The QuizWidget Component

Create src/components/QuizWidget.vue:

1<script setup lang="ts"> 2import { onMounted } from "vue"; 3import { useQuiz } from "@/composables/useQuiz"; 4 5const props = defineProps<{ 6 quizId: string; 7}>(); 8 9const { 10 quiz, 11 currentQuestion, 12 currentIndex, 13 totalQuestions, 14 progress, 15 isComplete, 16 isFinished, 17 loading, 18 submitting, 19 error, 20 result, 21 answers, 22 load, 23 selectAnswer, 24 next, 25 previous, 26 submit, 27 reset, 28} = useQuiz(props.quizId); 29 30onMounted(load); 31</script> 32 33<template> 34 <div class="quiz-widget"> 35 <!-- Loading State --> 36 <div v-if="loading" class="quiz-loading"> 37 <p>Loading quiz...</p> 38 </div> 39 40 <!-- Error State --> 41 <div v-else-if="error" class="quiz-error"> 42 <p>{{ error }}</p> 43 <button @click="load">Try Again</button> 44 </div> 45 46 <!-- Results State --> 47 <div v-else-if="isFinished && result" class="quiz-results"> 48 <h2>Quiz Complete</h2> 49 <div class="score"> 50 <span class="score-value">{{ result.score }}/{{ result.total }}</span> 51 <span class="score-percent"> 52 {{ Math.round((result.score / result.total) * 100) }}% 53 </span> 54 </div> 55 56 <ul class="result-details"> 57 <li 58 v-for="detail in result.details" 59 :key="detail.questionId" 60 :class="{ correct: detail.correct, incorrect: !detail.correct }" 61 > 62 <span class="indicator">{{ detail.correct ? "Correct" : "Incorrect" }}</span> 63 <p v-if="detail.explanation" class="explanation">{{ detail.explanation }}</p> 64 </li> 65 </ul> 66 67 <button @click="reset" class="btn-secondary">Retake Quiz</button> 68 </div> 69 70 <!-- Quiz State --> 71 <div v-else-if="quiz && currentQuestion" class="quiz-active"> 72 <header class="quiz-header"> 73 <h2>{{ quiz.title }}</h2> 74 <div class="progress-bar"> 75 <div class="progress-fill" :style="{ width: `${progress}%` }" /> 76 </div> 77 <span class="question-count"> 78 Question {{ currentIndex + 1 }} of {{ totalQuestions }} 79 </span> 80 </header> 81 82 <div class="question-card"> 83 <h3>{{ currentQuestion.text }}</h3> 84 85 <div class="answers"> 86 <button 87 v-for="answer in currentQuestion.answers" 88 :key="answer.id" 89 @click="selectAnswer(currentQuestion.id, answer.id)" 90 :class="{ 91 selected: answers[currentQuestion.id] === answer.id, 92 }" 93 class="answer-option" 94 > 95 {{ answer.text }} 96 </button> 97 </div> 98 </div> 99 100 <div class="quiz-nav"> 101 <button 102 @click="previous" 103 :disabled="currentIndex === 0" 104 class="btn-secondary" 105 > 106 Previous 107 </button> 108 109 <button 110 v-if="currentIndex < totalQuestions - 1" 111 @click="next" 112 class="btn-primary" 113 > 114 Next 115 </button> 116 117 <button 118 v-else 119 @click="submit" 120 :disabled="!isComplete || submitting" 121 class="btn-primary" 122 > 123 {{ submitting ? "Submitting..." : "Submit" }} 124 </button> 125 </div> 126 </div> 127 </div> 128</template> 129 130<style scoped> 131.quiz-widget { 132 max-width: 640px; 133 margin: 0 auto; 134 font-family: system-ui, sans-serif; 135} 136 137.answer-option { 138 display: block; 139 width: 100%; 140 text-align: left; 141 padding: 12px 16px; 142 margin-bottom: 8px; 143 border: 2px solid #e2e8f0; 144 border-radius: 8px; 145 background: white; 146 cursor: pointer; 147 transition: border-color 0.15s; 148} 149 150.answer-option:hover { 151 border-color: #94a3b8; 152} 153 154.answer-option.selected { 155 border-color: #3b82f6; 156 background: #eff6ff; 157} 158 159.progress-bar { 160 height: 6px; 161 background: #e2e8f0; 162 border-radius: 3px; 163 margin: 8px 0; 164} 165 166.progress-fill { 167 height: 100%; 168 background: #3b82f6; 169 border-radius: 3px; 170 transition: width 0.3s; 171} 172 173.quiz-nav { 174 display: flex; 175 justify-content: space-between; 176 margin-top: 24px; 177} 178 179.btn-primary { 180 padding: 8px 24px; 181 background: #3b82f6; 182 color: white; 183 border: none; 184 border-radius: 8px; 185 cursor: pointer; 186} 187 188.btn-primary:disabled { 189 opacity: 0.5; 190 cursor: not-allowed; 191} 192 193.btn-secondary { 194 padding: 8px 24px; 195 background: #f1f5f9; 196 border: none; 197 border-radius: 8px; 198 cursor: pointer; 199} 200 201.score-value { 202 font-size: 3rem; 203 font-weight: bold; 204 color: #3b82f6; 205} 206 207.correct { 208 color: #16a34a; 209} 210 211.incorrect { 212 color: #dc2626; 213} 214</style>

Using the Widget

Drop the widget anywhere in your app:

1<template> 2 <div class="container"> 3 <h1>Test Your JavaScript Knowledge</h1> 4 <QuizWidget quiz-id="js-fundamentals-2026" /> 5 </div> 6</template> 7 8<script setup lang="ts"> 9import QuizWidget from "@/components/QuizWidget.vue"; 10</script>

You can also render multiple quizzes on the same page. Each widget instance manages its own state through the composable:

<template> <QuizWidget quiz-id="html-basics" /> <QuizWidget quiz-id="css-selectors" /> </template>

Summary

The Composition API makes quiz state management straightforward. The useQuiz composable encapsulates all the logic - fetching, navigation, answer tracking, submission - while the component template stays declarative and easy to follow.

Key patterns used:

  • Composable for encapsulated, reusable logic
  • readonly refs to prevent accidental mutation from templates
  • Computed properties for derived state like progress and completion
  • Scoped styles to avoid leaking CSS when embedded in other pages

Next steps: add a timer for timed quizzes, persist answers to localStorage for resuming later, or create a quiz builder component that lets users create questions through a form.

Test Your Knowledge

Think you understand Tutorial? Put your skills to the test with hands-on quiz questions.

Tutorial
Start Practicing

Enjoyed this article?

Share it with your team or try our quiz platform.

Stay Updated

Get the latest tutorials and API tips delivered to your inbox.

No spam, unsubscribe anytime.