Building a Quiz Import/Export System
Design a robust import/export system for quizzes with JSON and CSV support, validation schemas, bulk operations, and clear error reporting.
Why Import/Export Matters
Quiz content rarely lives in one place. Instructors create questions in spreadsheets, content teams export from other platforms, and developers seed databases from JSON files. A solid import/export system lets quiz content flow between these tools without manual re-entry.
This guide covers building a quiz import/export system that handles JSON and CSV formats, validates data thoroughly, processes bulk imports efficiently, and returns clear error messages when things go wrong.
Export Format Design
Start with the export format because it defines the contract. A well-designed export format is also a valid import format.
JSON Format
interface QuizExport {
version: "1.0";
exportedAt: string;
quizzes: Array<{
title: string;
description: string | null;
category: string;
difficulty: "easy" | "medium" | "hard";
tags: string[];
questions: Array<{
text: string;
type: "multiple_choice" | "true_false" | "fill_blank";
difficulty: "easy" | "medium" | "hard";
points: number;
answers: Array<{
text: string;
isCorrect: boolean;
}>;
explanation: string | null;
}>;
}>;
}
Example export:
{
"version": "1.0",
"exportedAt": "2026-04-08T12:00:00Z",
"quizzes": [
{
"title": "JavaScript Fundamentals",
"description": "Test your core JS knowledge",
"category": "JavaScript",
"difficulty": "medium",
"tags": ["javascript", "fundamentals", "web"],
"questions": [
{
"text": "What does typeof null return?",
"type": "multiple_choice",
"difficulty": "medium",
"points": 10,
"answers": [
{ "text": "\"null\"", "isCorrect": false },
{ "text": "\"object\"", "isCorrect": true },
{ "text": "\"undefined\"", "isCorrect": false },
{ "text": "\"boolean\"", "isCorrect": false }
],
"explanation": "typeof null returns 'object' due to a legacy bug in JavaScript's type system."
}
]
}
]
}
CSV Format
CSV works for simpler imports, especially from spreadsheets. Use a flat structure with one row per answer:
quiz_title,question_text,question_type,question_difficulty,question_points,answer_text,answer_is_correct,explanation
JavaScript Fundamentals,What does typeof null return?,multiple_choice,medium,10,"null",false,"typeof null returns 'object' due to a legacy bug."
JavaScript Fundamentals,What does typeof null return?,multiple_choice,medium,10,"object",true,"typeof null returns 'object' due to a legacy bug."
JavaScript Fundamentals,What does typeof null return?,multiple_choice,medium,10,"undefined",false,"typeof null returns 'object' due to a legacy bug."
JavaScript Fundamentals,What does typeof null return?,multiple_choice,medium,10,"boolean",false,"typeof null returns 'object' due to a legacy bug."
Validation Schema
Use Zod for thorough validation with descriptive error messages:
import { z } from "zod";
const AnswerSchema = z.object({
text: z.string().min(1, "Answer text cannot be empty").max(500),
isCorrect: z.boolean(),
});
const QuestionSchema = z
.object({
text: z.string().min(10, "Question text must be at least 10 characters").max(2000),
type: z.enum(["multiple_choice", "true_false", "fill_blank"]),
difficulty: z.enum(["easy", "medium", "hard"]),
points: z.number().int().min(1).max(100),
answers: z.array(AnswerSchema).min(2).max(6),
explanation: z.string().max(1000).nullable(),
})
.refine(
(q) => q.answers.filter((a) => a.isCorrect).length >= 1,
{ message: "Each question must have at least one correct answer" }
)
.refine(
(q) => {
if (q.type === "true_false") return q.answers.length === 2;
if (q.type === "multiple_choice") return q.answers.length >= 3;
return true;
},
{ message: "Answer count does not match question type" }
);
const QuizSchema = z.object({
title: z.string().min(3).max(200),
description: z.string().max(1000).nullable(),
category: z.string().min(1).max(100),
difficulty: z.enum(["easy", "medium", "hard"]),
tags: z.array(z.string().max(50)).max(10).default([]),
questions: z.array(QuestionSchema).min(1).max(200),
});
const ImportSchema = z.object({
version: z.literal("1.0"),
quizzes: z.array(QuizSchema).min(1).max(50),
});
JSON Import Handler
interface ImportResult {
success: boolean;
imported: number;
failed: number;
errors: Array<{
quizIndex: number;
quizTitle: string;
questionIndex?: number;
message: string;
}>;
}
async function importFromJSON(
rawData: unknown,
userId: string
): Promise<ImportResult> {
// Validate structure
const parseResult = ImportSchema.safeParse(rawData);
if (!parseResult.success) {
return {
success: false,
imported: 0,
failed: 0,
errors: parseResult.error.errors.map((e) => ({
quizIndex: 0,
quizTitle: "unknown",
message: `${e.path.join(".")}: ${e.message}`,
})),
};
}
const data = parseResult.data;
const errors: ImportResult["errors"] = [];
let imported = 0;
let failed = 0;
for (let i = 0; i < data.quizzes.length; i++) {
const quizData = data.quizzes[i];
try {
// Check for duplicates
const existing = await prisma.quiz.findFirst({
where: {
title: quizData.title,
createdById: userId,
},
});
if (existing) {
errors.push({
quizIndex: i,
quizTitle: quizData.title,
message: `Quiz "${quizData.title}" already exists. Skipping.`,
});
failed++;
continue;
}
// Create quiz with all related data in a transaction
await prisma.$transaction(async (tx) => {
const quiz = await tx.quiz.create({
data: {
title: quizData.title,
description: quizData.description,
category: quizData.category,
difficulty: quizData.difficulty,
tags: quizData.tags,
createdById: userId,
published: false, // Imported quizzes start as drafts
},
});
for (let j = 0; j < quizData.questions.length; j++) {
const questionData = quizData.questions[j];
await tx.question.create({
data: {
text: questionData.text,
type: questionData.type,
difficulty: questionData.difficulty,
points: questionData.points,
explanation: questionData.explanation,
quizId: quiz.id,
sortOrder: j,
answers: {
create: questionData.answers.map((answer, k) => ({
text: answer.text,
isCorrect: answer.isCorrect,
sortOrder: k,
})),
},
},
});
}
});
imported++;
} catch (err) {
errors.push({
quizIndex: i,
quizTitle: quizData.title,
message: err instanceof Error ? err.message : "Unknown error",
});
failed++;
}
}
return {
success: failed === 0,
imported,
failed,
errors,
};
}
CSV Import with Row Grouping
CSV requires grouping rows into questions and quizzes:
import { parse } from "csv-parse/sync";
interface CSVRow {
quiz_title: string;
question_text: string;
question_type: string;
question_difficulty: string;
question_points: string;
answer_text: string;
answer_is_correct: string;
explanation: string;
}
function csvToQuizExport(csvContent: string): z.infer<typeof ImportSchema> {
const rows: CSVRow[] = parse(csvContent, {
columns: true,
skip_empty_lines: true,
trim: true,
});
// Group rows by quiz title, then by question text
const quizMap = new Map<string, Map<string, CSVRow[]>>();
for (const row of rows) {
if (!quizMap.has(row.quiz_title)) {
quizMap.set(row.quiz_title, new Map());
}
const questionMap = quizMap.get(row.quiz_title)!;
if (!questionMap.has(row.question_text)) {
questionMap.set(row.question_text, []);
}
questionMap.get(row.question_text)!.push(row);
}
const quizzes = Array.from(quizMap.entries()).map(([title, questionMap]) => {
const questions = Array.from(questionMap.entries()).map(
([text, answerRows]) => {
const firstRow = answerRows[0];
return {
text,
type: firstRow.question_type as "multiple_choice" | "true_false" | "fill_blank",
difficulty: firstRow.question_difficulty as "easy" | "medium" | "hard",
points: parseInt(firstRow.question_points, 10) || 10,
answers: answerRows.map((row) => ({
text: row.answer_text,
isCorrect: row.answer_is_correct.toLowerCase() === "true",
})),
explanation: firstRow.explanation || null,
};
}
);
return {
title,
description: null,
category: "Imported",
difficulty: "medium" as const,
tags: [],
questions,
};
});
return { version: "1.0" as const, quizzes };
}
Export Endpoint
app.get("/api/v1/quizzes/export", async (req, res) => {
const { format = "json", quizIds } = req.query;
const userId = req.user!.id;
const where: any = { createdById: userId };
if (quizIds) {
where.id = { in: (quizIds as string).split(",") };
}
const quizzes = await prisma.quiz.findMany({
where,
include: {
questions: {
include: { answers: true },
orderBy: { sortOrder: "asc" },
},
},
orderBy: { createdAt: "desc" },
});
const exportData: z.infer<typeof ImportSchema> = {
version: "1.0",
quizzes: quizzes.map((quiz) => ({
title: quiz.title,
description: quiz.description,
category: quiz.category,
difficulty: quiz.difficulty,
tags: quiz.tags,
questions: quiz.questions.map((q) => ({
text: q.text,
type: q.type,
difficulty: q.difficulty,
points: q.points,
answers: q.answers.map((a) => ({
text: a.text,
isCorrect: a.isCorrect,
})),
explanation: q.explanation,
})),
})),
};
if (format === "csv") {
const csv = quizExportToCSV(exportData);
res.setHeader("Content-Type", "text/csv");
res.setHeader("Content-Disposition", "attachment; filename=quizzes.csv");
return res.send(csv);
}
res.setHeader("Content-Type", "application/json");
res.setHeader("Content-Disposition", "attachment; filename=quizzes.json");
res.json(exportData);
});
function quizExportToCSV(data: z.infer<typeof ImportSchema>): string {
const header =
"quiz_title,question_text,question_type,question_difficulty,question_points,answer_text,answer_is_correct,explanation\n";
const rows = data.quizzes.flatMap((quiz) =>
quiz.questions.flatMap((question) =>
question.answers.map(
(answer) =>
[
csvEscape(quiz.title),
csvEscape(question.text),
question.type,
question.difficulty,
question.points,
csvEscape(answer.text),
answer.isCorrect,
csvEscape(question.explanation ?? ""),
].join(",")
)
)
);
return header + rows.join("\n");
}
function csvEscape(value: string): string {
if (value.includes(",") || value.includes('"') || value.includes("\n")) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}
API Route for Import
import multer from "multer";
const upload = multer({
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB max
fileFilter: (req, file, cb) => {
const allowed = ["application/json", "text/csv"];
if (allowed.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error("Only JSON and CSV files are accepted"));
}
},
});
app.post("/api/v1/quizzes/import", upload.single("file"), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: "No file uploaded" });
}
const content = req.file.buffer.toString("utf-8");
const userId = req.user!.id;
let result: ImportResult;
if (req.file.mimetype === "text/csv") {
try {
const parsed = csvToQuizExport(content);
result = await importFromJSON(parsed, userId);
} catch (err) {
return res.status(400).json({
error: "Failed to parse CSV",
message: err instanceof Error ? err.message : "Unknown error",
});
}
} else {
try {
const parsed = JSON.parse(content);
result = await importFromJSON(parsed, userId);
} catch (err) {
return res.status(400).json({
error: "Failed to parse JSON",
message: err instanceof Error ? err.message : "Invalid JSON",
});
}
}
const status = result.success ? 200 : 207; // 207 Multi-Status for partial success
res.status(status).json(result);
});
Summary
A quiz import/export system needs three things: a well-defined format, thorough validation, and clear error reporting. The JSON format handles complex quiz structures with nested questions and answers. CSV handles simpler use cases and spreadsheet workflows. Zod validation catches structural problems before they hit the database, and per-quiz error tracking tells users exactly which items failed and why.
Start with JSON export - it is the most complete format and doubles as your import format. Add CSV support when your users ask for it. And always import quizzes as unpublished drafts so authors can review before going live.
Stay Updated
Get the latest tutorials and API tips delivered to your inbox.
No spam, unsubscribe anytime.
Related Articles
Monitoring Quiz API Performance with Prometheus and Grafana
Instrument your quiz API with Prometheus metrics, build Grafana dashboards, and set up alerts that catch problems before users notice.
Rate Limiting Your Quiz API: A Practical Guide
Protect your quiz API from abuse with token bucket and sliding window rate limiters. Includes Redis-based implementation and graceful 429 handling.
Scaling Quiz Delivery: From 100 to 100,000 Concurrent Players
Scale your quiz platform to handle massive concurrent load with database optimization, caching, connection pooling, and read replicas.
Enjoyed this article?
Share it with your team or try our quiz platform.