QuizAPI + Next.js: Server-Side Quiz Rendering
Fetch quizzes in React Server Components and render them with streaming for instant page loads. No client-side loading spinners required.
Why Server-Side Quiz Rendering Matters
Every millisecond of loading time costs you quiz completions. When a user clicks "Start Quiz" and sees a spinner while your client fetches questions from an API, you lose engagement. Server Components in Next.js let you fetch quiz data on the server and stream HTML to the browser - the quiz appears instantly with zero client-side fetching.
In this tutorial, you will build a server-rendered quiz page that fetches from QuizAPI, streams question cards progressively, and hydrates only the interactive parts on the client.
Prerequisites
- Node.js 20+
- Next.js 16 with App Router
- A QuizAPI API key (grab one at quizapi.io)
- Basic familiarity with React Server Components
Project Setup
Scaffold a new Next.js app and install dependencies:
npx create-next-app@latest quiz-ssr --typescript --tailwind --app
cd quiz-ssr
npm install zod
Create an environment file for your API key:
# .env.local
QUIZAPI_API_KEY=your_api_key_here
QUIZAPI_BASE_URL=https://quizapi.io/api/v1
Fetching Quizzes in Server Components
The key insight with Server Components is that fetch calls run on the server at request time. No API keys leak to the client, and the data is ready before any HTML ships to the browser.
Create a typed API client at lib/quizapi.ts:
import { z } from "zod";
const AnswerSchema = z.object({
id: z.string(),
text: z.string(),
isCorrect: z.boolean().optional(),
});
const QuestionSchema = z.object({
id: z.string(),
text: z.string(),
type: z.enum(["multiple_choice", "true_false", "fill_blank"]),
difficulty: z.enum(["easy", "medium", "hard"]),
answers: z.array(AnswerSchema),
explanation: z.string().nullable(),
});
const QuizSchema = z.object({
id: z.string(),
title: z.string(),
description: z.string().nullable(),
category: z.string(),
questions: z.array(QuestionSchema),
});
export type Quiz = z.infer<typeof QuizSchema>;
export type Question = z.infer<typeof QuestionSchema>;
export async function getQuiz(quizId: string): Promise<Quiz> {
const res = await fetch(
`${process.env.QUIZAPI_BASE_URL}/quizzes/${quizId}`,
{
headers: {
Authorization: `Bearer ${process.env.QUIZAPI_API_KEY}`,
},
next: { revalidate: 300 }, // cache for 5 minutes
}
);
if (!res.ok) {
throw new Error(`Failed to fetch quiz: ${res.status}`);
}
const data = await res.json();
return QuizSchema.parse(data);
}
export async function getQuizzes(category?: string): Promise<Quiz[]> {
const params = new URLSearchParams();
if (category) params.set("category", category);
const res = await fetch(
`${process.env.QUIZAPI_BASE_URL}/quizzes?${params.toString()}`,
{
headers: {
Authorization: `Bearer ${process.env.QUIZAPI_API_KEY}`,
},
next: { revalidate: 60 },
}
);
if (!res.ok) {
throw new Error(`Failed to fetch quizzes: ${res.status}`);
}
const data = await res.json();
return z.array(QuizSchema).parse(data);
}
Zod validates the API response at runtime. If the response shape changes, you get a clear error instead of a silent rendering bug.
Building the Quiz Page with Streaming
Create the quiz detail page at app/quiz/[id]/page.tsx:
import { Suspense } from "react";
import { getQuiz } from "@/lib/quizapi";
import { QuizPlayer } from "./quiz-player";
import { QuestionListSkeleton } from "./skeleton";
interface QuizPageProps {
params: Promise<{ id: string }>;
}
export default async function QuizPage({ params }: QuizPageProps) {
const { id } = await params;
return (
<main className="max-w-3xl mx-auto px-4 py-8">
<Suspense fallback={<QuestionListSkeleton />}>
<QuizContent quizId={id} />
</Suspense>
</main>
);
}
async function QuizContent({ quizId }: { quizId: string }) {
const quiz = await getQuiz(quizId);
return (
<div>
<header className="mb-8">
<h1 className="text-3xl font-bold">{quiz.title}</h1>
{quiz.description && (
<p className="text-gray-600 mt-2">{quiz.description}</p>
)}
<div className="flex gap-3 mt-4 text-sm text-gray-500">
<span>{quiz.questions.length} questions</span>
<span>{quiz.category}</span>
</div>
</header>
<QuizPlayer questions={quiz.questions} quizId={quiz.id} />
</div>
);
}
The Suspense boundary is what enables streaming. Next.js sends the page shell immediately and streams QuizContent as soon as the fetch resolves.
Client-Side Quiz State
Only the interactive quiz player needs to be a Client Component. Create app/quiz/[id]/quiz-player.tsx:
"use client";
import { useState, useCallback } from "react";
import type { Question } from "@/lib/quizapi";
interface QuizPlayerProps {
questions: Question[];
quizId: string;
}
interface QuizState {
currentIndex: number;
answers: Record<string, string>;
submitted: boolean;
score: number | null;
}
export function QuizPlayer({ questions, quizId }: QuizPlayerProps) {
const [state, setState] = useState<QuizState>({
currentIndex: 0,
answers: {},
submitted: false,
score: null,
});
const currentQuestion = questions[state.currentIndex];
const selectAnswer = useCallback((questionId: string, answerId: string) => {
setState((prev) => ({
...prev,
answers: { ...prev.answers, [questionId]: answerId },
}));
}, []);
const goToNext = useCallback(() => {
setState((prev) => ({
...prev,
currentIndex: Math.min(prev.currentIndex + 1, questions.length - 1),
}));
}, [questions.length]);
const goToPrevious = useCallback(() => {
setState((prev) => ({
...prev,
currentIndex: Math.max(prev.currentIndex - 1, 0),
}));
}, []);
const submitQuiz = useCallback(async () => {
const res = await fetch(`/api/quizzes/${quizId}/submit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ answers: state.answers }),
});
const result = await res.json();
setState((prev) => ({ ...prev, submitted: true, score: result.score }));
}, [quizId, state.answers]);
if (state.submitted && state.score !== null) {
return (
<div className="text-center py-12">
<h2 className="text-4xl font-bold">
{state.score}/{questions.length}
</h2>
<p className="text-gray-600 mt-2">
You got {Math.round((state.score / questions.length) * 100)}% correct
</p>
</div>
);
}
return (
<div>
<div className="mb-4 text-sm text-gray-500">
Question {state.currentIndex + 1} of {questions.length}
</div>
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4">{currentQuestion.text}</h2>
<div className="space-y-3">
{currentQuestion.answers.map((answer) => (
<button
key={answer.id}
onClick={() => selectAnswer(currentQuestion.id, answer.id)}
className={`w-full text-left p-4 rounded-lg border-2 transition-colors ${
state.answers[currentQuestion.id] === answer.id
? "border-blue-500 bg-blue-50"
: "border-gray-200 hover:border-gray-300"
}`}
>
{answer.text}
</button>
))}
</div>
</div>
<div className="flex justify-between mt-6">
<button
onClick={goToPrevious}
disabled={state.currentIndex === 0}
className="px-4 py-2 bg-gray-100 rounded-lg disabled:opacity-50"
>
Previous
</button>
{state.currentIndex === questions.length - 1 ? (
<button
onClick={submitQuiz}
disabled={Object.keys(state.answers).length < questions.length}
className="px-6 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50"
>
Submit Quiz
</button>
) : (
<button
onClick={goToNext}
className="px-4 py-2 bg-blue-600 text-white rounded-lg"
>
Next
</button>
)}
</div>
</div>
);
}
Notice how the questions arrive as props from the Server Component. There is no loading state, no useEffect to fetch data. The quiz is ready to play the moment the page renders.
Adding Loading Skeletons
Create app/quiz/[id]/skeleton.tsx for the streaming fallback:
export function QuestionListSkeleton() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4" />
<div className="h-4 bg-gray-200 rounded w-1/2 mb-8" />
<div className="bg-white rounded-lg shadow p-6">
<div className="h-6 bg-gray-200 rounded w-full mb-6" />
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-14 bg-gray-100 rounded-lg" />
))}
</div>
</div>
</div>
);
}
Caching Strategy
The next: { revalidate: 300 } option in our fetch call tells Next.js to cache the quiz data for 5 minutes. This means the first visitor triggers a server fetch, but subsequent visitors get cached HTML instantly.
For quizzes that update frequently, you can use on-demand revalidation:
// app/api/revalidate/route.ts
import { revalidatePath } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const { quizId, secret } = await request.json();
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ error: "Invalid secret" }, { status: 401 });
}
revalidatePath(`/quiz/${quizId}`);
return NextResponse.json({ revalidated: true });
}
Call this endpoint from your CMS or admin panel whenever quiz content changes.
Summary
Server Components flip the mental model for data fetching. Instead of rendering a shell and fetching on the client, you fetch on the server and stream the result. For quiz applications, this translates to faster time-to-interactive and no exposed API keys.
Key takeaways:
- Use Server Components to fetch quiz data - no client-side loading states needed
- Wrap async components in
Suspensefor streaming - Keep only interactive parts (answer selection, navigation) as Client Components
- Validate API responses with Zod to catch shape mismatches early
- Use
next: { revalidate }for time-based caching andrevalidatePathfor on-demand updates
Next, consider adding error boundaries for graceful failure handling and parallel data fetching when your quiz page needs data from multiple sources.
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.