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

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
  • 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.

Stay Updated

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

No spam, unsubscribe anytime.

Enjoyed this article?

Share it with your team or try our quiz platform.