Skip to content
Command Palette
Search for a command to run...
QuizBase · Docs

Build a Polish quiz app#

A complete walkthrough. By the end you have a SvelteKit app that serves Polish trivia with score tracking, a per-question timer, and visible attribution. Total code: ~120 lines.

Works with any framework — the QuizBase calls are framework-agnostic.

1. Fetch from your server#

Never put qb_sk_* in client bundles. Hide the call behind your own endpoint.

// src/routes/api/round/+server.ts
import { json } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';

export async function GET() {
  const url = new URL('https://quizbase.runriva.com/api/v1/questions/random');
  url.searchParams.set('amount', '10');
  url.searchParams.set('lang', 'pl');
  url.searchParams.set('type', 'multiple');
  url.searchParams.set('difficulty', 'medium');

  const res = await fetch(url, {
    headers: { 'X-API-Key': env.QUIZBASE_KEY }
  });

  if (!res.ok) {
    return json({ error: 'upstream' }, { status: 502 });
  }

  const { data, meta } = await res.json();
  return json({ questions: data, requestId: meta.requestId });
}

2. Shuffle answers in the component#

QuizBase returns correctAnswer + incorrectAnswers separately. Shuffle once per render to avoid the correct answer always being in the same slot.

// src/lib/quiz.ts
export type Question = {
  id: string;
  text: string;
  correctAnswer: string;
  incorrectAnswers: string[];
  attribution: { author: string; source: string; license: string; url: string };
};

export function prepareChoices(q: Question): Array<{ text: string; correct: boolean }> {
  const choices = [
    { text: q.correctAnswer, correct: true },
    ...q.incorrectAnswers.map((t) => ({ text: t, correct: false }))
  ];
  // Fisher-Yates in place
  for (let i = choices.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [choices[i], choices[j]] = [choices[j]!, choices[i]!];
  }
  return choices;
}

3. UI — one question at a time#

<!-- src/routes/+page.svelte -->
<script lang="ts">
  import { onMount } from 'svelte';
  import { prepareChoices, type Question } from '$lib/quiz';

  let questions: Question[] = $state([]);
  let index = $state(0);
  let score = $state(0);
  let picked = $state<string | null>(null);
  let secondsLeft = $state(15);

  const current = $derived(questions[index]);
  const choices = $derived(current ? prepareChoices(current) : []);

  onMount(async () => {
    const res = await fetch('/api/round');
    const body = await res.json();
    questions = body.questions;
    startTimer();
  });

  function startTimer() {
    secondsLeft = 15;
    const handle = setInterval(() => {
      if (--secondsLeft <= 0) {
        clearInterval(handle);
        next();
      }
    }, 1000);
  }

  function pick(text: string, correct: boolean) {
    picked = text;
    if (correct) score++;
  }

  function next() {
    picked = null;
    if (index < questions.length - 1) {
      index++;
      startTimer();
    }
  }
</script>

{#if current}
  <article>
    <header>
      <p>Pytanie {index + 1} / {questions.length} · {secondsLeft}s · wynik {score}</p>
    </header>

    <h1>{current.text}</h1>

    <ul>
      {#each choices as choice (choice.text)}
        <li>
          <button
            disabled={picked !== null}
            class:correct={picked && choice.correct}
            class:wrong={picked === choice.text && !choice.correct}
            onclick={() => pick(choice.text, choice.correct)}
          >
            {choice.text}
          </button>
        </li>
      {/each}
    </ul>

    {#if picked !== null}
      <button onclick={next}>Dalej →</button>
    {/if}

    <!-- Attribution — required by CC-BY-SA chain -->
    <footer>
      <small>
        Źródło: <a href={current.attribution.url}>{current.attribution.source}</a>
        · Licencja {current.attribution.license}
      </small>
    </footer>
  </article>
{/if}

4. Edge cases#

  • Empty data[] — your filters matched nothing. Broaden difficulty or drop category.
  • Duplicate questions across rounds — pass exclude=<comma-separated-ids> (max 100). Persist shown ids per session.
  • Rate limited (429) — cache rounds server-side (Cache-Control: private, max-age=300) or pre-fetch batches.

5. Deploy#

  • Put QUIZBASE_KEY in your hosting provider’s env (Railway, Vercel, Fly, Cloudflare).
  • Use qb_pk_* locally, swap for qb_sk_* in production.
  • Add Cache-Control: public, max-age=300 on /api/round if acceptable.

See also#