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.
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:
export interface Answer {
id: string;
text: string;
isCorrect?: boolean;
}
export interface Question {
id: string;
text: string;
type: "multiple_choice" | "true_false";
answers: Answer[];
explanation: string | null;
}
export interface Quiz {
id: string;
title: string;
description: string | null;
questions: Question[];
}
export interface QuizResult {
score: number;
total: number;
details: Array<{
questionId: string;
correct: boolean;
explanation: string | null;
}>;
}
Create the API client at src/api/quizapi.ts:
import type { Quiz, QuizResult } from "@/types/quiz";
const BASE_URL = import.meta.env.VITE_QUIZAPI_URL;
const API_KEY = import.meta.env.VITE_QUIZAPI_KEY;
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, {
...options,
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
...options?.headers,
},
});
if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`);
}
return res.json();
}
export function fetchQuiz(quizId: string): Promise<Quiz> {
return apiFetch<Quiz>(`/quizzes/${quizId}`);
}
export function submitQuiz(
quizId: string,
answers: Record<string, string>
): Promise<QuizResult> {
return apiFetch<QuizResult>(`/quizzes/${quizId}/submit`, {
method: "POST",
body: JSON.stringify({ answers }),
});
}
The Quiz Composable
The composable is where all the quiz logic lives. Create src/composables/useQuiz.ts:
import { ref, computed, readonly } from "vue";
import type { Quiz, Question, QuizResult } from "@/types/quiz";
import { fetchQuiz, submitQuiz } from "@/api/quizapi";
export function useQuiz(quizId: string) {
const quiz = ref<Quiz | null>(null);
const answers = ref<Record<string, string>>({});
const currentIndex = ref(0);
const loading = ref(false);
const error = ref<string | null>(null);
const result = ref<QuizResult | null>(null);
const submitting = ref(false);
const currentQuestion = computed<Question | null>(() => {
if (!quiz.value) return null;
return quiz.value.questions[currentIndex.value] ?? null;
});
const totalQuestions = computed(() => quiz.value?.questions.length ?? 0);
const progress = computed(() => {
if (totalQuestions.value === 0) return 0;
return Math.round(
(Object.keys(answers.value).length / totalQuestions.value) * 100
);
});
const isComplete = computed(() => {
return Object.keys(answers.value).length === totalQuestions.value;
});
const isFinished = computed(() => result.value !== null);
async function load() {
loading.value = true;
error.value = null;
try {
quiz.value = await fetchQuiz(quizId);
} catch (err) {
error.value = err instanceof Error ? err.message : "Failed to load quiz";
} finally {
loading.value = false;
}
}
function selectAnswer(questionId: string, answerId: string) {
answers.value = { ...answers.value, [questionId]: answerId };
}
function next() {
if (currentIndex.value < totalQuestions.value - 1) {
currentIndex.value++;
}
}
function previous() {
if (currentIndex.value > 0) {
currentIndex.value--;
}
}
function goTo(index: number) {
if (index >= 0 && index < totalQuestions.value) {
currentIndex.value = index;
}
}
async function submit() {
submitting.value = true;
error.value = null;
try {
result.value = await submitQuiz(quizId, answers.value);
} catch (err) {
error.value =
err instanceof Error ? err.message : "Failed to submit quiz";
} finally {
submitting.value = false;
}
}
function reset() {
answers.value = {};
currentIndex.value = 0;
result.value = null;
}
return {
quiz: readonly(quiz),
currentQuestion,
currentIndex: readonly(currentIndex),
totalQuestions,
progress,
isComplete,
isFinished,
loading: readonly(loading),
submitting: readonly(submitting),
error: readonly(error),
result: readonly(result),
answers: readonly(answers),
load,
selectAnswer,
next,
previous,
goTo,
submit,
reset,
};
}
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:
<script setup lang="ts">
import { onMounted } from "vue";
import { useQuiz } from "@/composables/useQuiz";
const props = defineProps<{
quizId: string;
}>();
const {
quiz,
currentQuestion,
currentIndex,
totalQuestions,
progress,
isComplete,
isFinished,
loading,
submitting,
error,
result,
answers,
load,
selectAnswer,
next,
previous,
submit,
reset,
} = useQuiz(props.quizId);
onMounted(load);
</script>
<template>
<div class="quiz-widget">
<!-- Loading State -->
<div v-if="loading" class="quiz-loading">
<p>Loading quiz...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="quiz-error">
<p>{{ error }}</p>
<button @click="load">Try Again</button>
</div>
<!-- Results State -->
<div v-else-if="isFinished && result" class="quiz-results">
<h2>Quiz Complete</h2>
<div class="score">
<span class="score-value">{{ result.score }}/{{ result.total }}</span>
<span class="score-percent">
{{ Math.round((result.score / result.total) * 100) }}%
</span>
</div>
<ul class="result-details">
<li
v-for="detail in result.details"
:key="detail.questionId"
:class="{ correct: detail.correct, incorrect: !detail.correct }"
>
<span class="indicator">{{ detail.correct ? "Correct" : "Incorrect" }}</span>
<p v-if="detail.explanation" class="explanation">{{ detail.explanation }}</p>
</li>
</ul>
<button @click="reset" class="btn-secondary">Retake Quiz</button>
</div>
<!-- Quiz State -->
<div v-else-if="quiz && currentQuestion" class="quiz-active">
<header class="quiz-header">
<h2>{{ quiz.title }}</h2>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: `${progress}%` }" />
</div>
<span class="question-count">
Question {{ currentIndex + 1 }} of {{ totalQuestions }}
</span>
</header>
<div class="question-card">
<h3>{{ currentQuestion.text }}</h3>
<div class="answers">
<button
v-for="answer in currentQuestion.answers"
:key="answer.id"
@click="selectAnswer(currentQuestion.id, answer.id)"
:class="{
selected: answers[currentQuestion.id] === answer.id,
}"
class="answer-option"
>
{{ answer.text }}
</button>
</div>
</div>
<div class="quiz-nav">
<button
@click="previous"
:disabled="currentIndex === 0"
class="btn-secondary"
>
Previous
</button>
<button
v-if="currentIndex < totalQuestions - 1"
@click="next"
class="btn-primary"
>
Next
</button>
<button
v-else
@click="submit"
:disabled="!isComplete || submitting"
class="btn-primary"
>
{{ submitting ? "Submitting..." : "Submit" }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.quiz-widget {
max-width: 640px;
margin: 0 auto;
font-family: system-ui, sans-serif;
}
.answer-option {
display: block;
width: 100%;
text-align: left;
padding: 12px 16px;
margin-bottom: 8px;
border: 2px solid #e2e8f0;
border-radius: 8px;
background: white;
cursor: pointer;
transition: border-color 0.15s;
}
.answer-option:hover {
border-color: #94a3b8;
}
.answer-option.selected {
border-color: #3b82f6;
background: #eff6ff;
}
.progress-bar {
height: 6px;
background: #e2e8f0;
border-radius: 3px;
margin: 8px 0;
}
.progress-fill {
height: 100%;
background: #3b82f6;
border-radius: 3px;
transition: width 0.3s;
}
.quiz-nav {
display: flex;
justify-content: space-between;
margin-top: 24px;
}
.btn-primary {
padding: 8px 24px;
background: #3b82f6;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
padding: 8px 24px;
background: #f1f5f9;
border: none;
border-radius: 8px;
cursor: pointer;
}
.score-value {
font-size: 3rem;
font-weight: bold;
color: #3b82f6;
}
.correct {
color: #16a34a;
}
.incorrect {
color: #dc2626;
}
</style>
Using the Widget
Drop the widget anywhere in your app:
<template>
<div class="container">
<h1>Test Your JavaScript Knowledge</h1>
<QuizWidget quiz-id="js-fundamentals-2026" />
</div>
</template>
<script setup lang="ts">
import QuizWidget from "@/components/QuizWidget.vue";
</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
readonlyrefs 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.
Stay Updated
Get the latest tutorials and API tips delivered to your inbox.
No spam, unsubscribe anytime.
Related Articles
How to Build a Quiz App with Django and QuizAPI
Step-by-step guide to building a quiz application with Django using the QuizAPI REST API. Fetch questions, render a quiz UI, and submit scores.
Building a Quiz Component in React with QuizAPI
Build a reusable React quiz component that fetches questions from QuizAPI, manages quiz state, and displays scores. Full TypeScript implementation included.
Building a Quiz Leaderboard with Real-Time Updates
Build a live quiz leaderboard with ranking algorithms, efficient data models, and real-time delivery using SSE and WebSockets.
Enjoyed this article?
Share it with your team or try our quiz platform.