Surveys and quizzes drive engagement and collect valuable data. Here is how to build a flexible one.
Step 1: Define Quiz Structure
// types/quiz.ts
export type QuestionType = "single" | "multiple" | "text" | "rating";
export interface Option {
id: string;
text: string;
score?: number;
}
export interface Question {
id: string;
type: QuestionType;
text: string;
description?: string;
options?: Option[];
required?: boolean;
maxSelections?: number;
}
export interface QuizConfig {
title: string;
description: string;
questions: Question[];
showScore?: boolean;
resultRanges?: { min: number; max: number; title: string; description: string }[];
}
Step 2: Sample Quiz Data
// data/quiz.ts
import type { QuizConfig } from "@/types/quiz";
export const sampleQuiz: QuizConfig = {
title: "Which Tech Stack Is Right for You?",
description: "Answer a few questions to find your ideal web development stack.",
showScore: true,
questions: [
{
id: "q1",
type: "single",
text: "What is the primary purpose of your project?",
required: true,
options: [
{ id: "a", text: "Marketing website", score: 1 },
{ id: "b", text: "Web application", score: 3 },
{ id: "c", text: "E-commerce store", score: 2 },
{ id: "d", text: "Blog or content site", score: 1 },
],
},
{
id: "q2",
type: "single",
text: "How important is SEO to your project?",
required: true,
options: [
{ id: "a", text: "Critical — it is our main traffic source", score: 3 },
{ id: "b", text: "Important but not critical", score: 2 },
{ id: "c", text: "Not very important", score: 1 },
],
},
{
id: "q3",
type: "multiple",
text: "Which features do you need?",
maxSelections: 3,
options: [
{ id: "a", text: "User authentication", score: 2 },
{ id: "b", text: "Real-time updates", score: 3 },
{ id: "c", text: "Payment processing", score: 2 },
{ id: "d", text: "Content management", score: 1 },
{ id: "e", text: "API integrations", score: 2 },
],
},
{
id: "q4",
type: "rating",
text: "How would you rate your team's technical expertise?",
required: true,
},
{
id: "q5",
type: "text",
text: "What is your biggest concern about the project?",
},
],
resultRanges: [
{ min: 0, max: 8, title: "Static Site Generator", description: "A simple static site with Astro or Hugo would be perfect for your needs." },
{ min: 9, max: 14, title: "Next.js Full-Stack", description: "Next.js with a database and authentication is your best bet." },
{ min: 15, max: 25, title: "Custom Web Application", description: "You need a custom-built web application with advanced features." },
],
};
Step 3: Quiz Component
"use client";
import { useState } from "react";
import type { QuizConfig, Question } from "@/types/quiz";
import { ChevronRight, ChevronLeft, Check } from "lucide-react";
interface Answers {
[questionId: string]: string | string[] | number;
}
export function Quiz({ config }: { config: QuizConfig }) {
const [currentStep, setCurrentStep] = useState(0);
const [answers, setAnswers] = useState<Answers>({});
const [isComplete, setIsComplete] = useState(false);
const question = config.questions[currentStep];
const totalSteps = config.questions.length;
const progress = ((currentStep + 1) / totalSteps) * 100;
function setAnswer(questionId: string, value: string | string[] | number) {
setAnswers((prev) => ({ ...prev, [questionId]: value }));
}
function next() {
if (currentStep < totalSteps - 1) {
setCurrentStep((s) => s + 1);
} else {
setIsComplete(true);
}
}
function prev() {
if (currentStep > 0) setCurrentStep((s) => s - 1);
}
function calculateScore(): number {
let score = 0;
for (const q of config.questions) {
const answer = answers[q.id];
if (!answer || !q.options) continue;
if (Array.isArray(answer)) {
for (const optId of answer) {
const opt = q.options.find((o) => o.id === optId);
if (opt?.score) score += opt.score;
}
} else if (typeof answer === "string") {
const opt = q.options.find((o) => o.id === answer);
if (opt?.score) score += opt.score;
} else if (typeof answer === "number") {
score += answer;
}
}
return score;
}
if (isComplete) {
const score = calculateScore();
const result = config.resultRanges?.find(
(r) => score >= r.min && score <= r.max
);
return (
<div className="mx-auto max-w-lg rounded-2xl border p-8 text-center dark:border-gray-700">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100 dark:bg-green-900">
<Check className="h-8 w-8 text-green-600 dark:text-green-400" />
</div>
<h2 className="text-2xl font-bold">{result?.title || "Complete!"}</h2>
{config.showScore && (
<p className="mt-2 text-3xl font-bold text-blue-600">{score} points</p>
)}
<p className="mt-3 text-gray-600 dark:text-gray-400">
{result?.description || "Thank you for completing the quiz."}
</p>
<button
onClick={() => {
setCurrentStep(0);
setAnswers({});
setIsComplete(false);
}}
className="mt-6 rounded-lg bg-blue-600 px-6 py-2 text-white hover:bg-blue-700"
>
Retake Quiz
</button>
</div>
);
}
return (
<div className="mx-auto max-w-lg rounded-2xl border p-6 dark:border-gray-700">
{/* Progress */}
<div className="mb-6">
<div className="mb-2 flex justify-between text-xs text-gray-500">
<span>Question {currentStep + 1} of {totalSteps}</span>
<span>{Math.round(progress)}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
className="h-full rounded-full bg-blue-600 transition-all"
style={{ width: `${progress}%` }}
/>
</div>
</div>
{/* Question */}
<h3 className="text-lg font-semibold">{question.text}</h3>
{question.description && (
<p className="mt-1 text-sm text-gray-500">{question.description}</p>
)}
{/* Answer Input */}
<div className="mt-4">
<QuestionInput
question={question}
answer={answers[question.id]}
onChange={(v) => setAnswer(question.id, v)}
/>
</div>
{/* Navigation */}
<div className="mt-6 flex justify-between">
<button
onClick={prev}
disabled={currentStep === 0}
className="flex items-center gap-1 rounded-lg border px-4 py-2 text-sm disabled:opacity-50 dark:border-gray-700"
>
<ChevronLeft className="h-4 w-4" /> Back
</button>
<button
onClick={next}
disabled={question.required && !answers[question.id]}
className="flex items-center gap-1 rounded-lg bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
{currentStep === totalSteps - 1 ? "Finish" : "Next"}
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
);
}
function QuestionInput({
question,
answer,
onChange,
}: {
question: Question;
answer: string | string[] | number | undefined;
onChange: (value: string | string[] | number) => void;
}) {
switch (question.type) {
case "single":
return (
<div className="space-y-2">
{question.options?.map((opt) => (
<button
key={opt.id}
onClick={() => onChange(opt.id)}
className={`w-full rounded-lg border p-3 text-left text-sm transition-all ${
answer === opt.id
? "border-blue-500 bg-blue-50 dark:bg-blue-950"
: "border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
}`}
>
{opt.text}
</button>
))}
</div>
);
case "multiple":
const selected = (answer as string[]) || [];
return (
<div className="space-y-2">
{question.options?.map((opt) => (
<button
key={opt.id}
onClick={() => {
if (selected.includes(opt.id)) {
onChange(selected.filter((id) => id !== opt.id));
} else if (!question.maxSelections || selected.length < question.maxSelections) {
onChange([...selected, opt.id]);
}
}}
className={`flex w-full items-center gap-3 rounded-lg border p-3 text-left text-sm transition-all ${
selected.includes(opt.id)
? "border-blue-500 bg-blue-50 dark:bg-blue-950"
: "border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
}`}
>
<div className={`flex h-4 w-4 items-center justify-center rounded border ${
selected.includes(opt.id) ? "border-blue-500 bg-blue-500" : "border-gray-300"
}`}>
{selected.includes(opt.id) && <Check className="h-3 w-3 text-white" />}
</div>
{opt.text}
</button>
))}
</div>
);
case "rating":
return (
<div className="flex justify-center gap-2">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
onClick={() => onChange(n)}
className={`flex h-12 w-12 items-center justify-center rounded-lg border text-lg font-semibold transition-all ${
(answer as number) === n
? "border-blue-500 bg-blue-600 text-white"
: "border-gray-200 hover:bg-gray-50 dark:border-gray-700"
}`}
>
{n}
</button>
))}
</div>
);
case "text":
return (
<textarea
value={(answer as string) || ""}
onChange={(e) => onChange(e.target.value)}
rows={3}
placeholder="Type your answer..."
className="w-full rounded-lg border p-3 text-sm dark:border-gray-700 dark:bg-gray-900"
/>
);
default:
return null;
}
}
Want Interactive Content on Your Site?
We build engaging web experiences with quizzes, surveys, calculators, and interactive content. Contact us to discuss your project.