Back to Blog
Engineering

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.

Bobby Iliev2026-04-087 min read

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.

Enjoyed this article?

Share it with your team or try our quiz platform.