How I Built a Real-Time Quiz Platform with Next.js, WebSockets, and Learning Science A developer built Quizotic, a free real-time quiz platform using Next.js 15, TypeScript, Prisma, PostgreSQL on Railway, and Socket.IO/WebSockets, that combines live interactive sessions with diagnostic reports grounded in Bloom's Taxonomy. The platform treats every question as both an interaction and a diagnostic object, enabling features like AI question generation, 19 slide types, and immutable quiz versions that preserve exact question snapshots for accurate post-session analysis. By storing active session state in memory for low-latency realtime leaderboards while persisting critical events to a relational database, the system ensures both engaging live experiences and auditable, deduplicated reports that reveal what kind of thinking was tested. A good live quiz has two jobs. First, it should make a room feel awake. Participants join quickly, tap answers from their phones, see feedback, and feel the tension of a leaderboard moving in real time. Second, it should help the teacher or trainer understand what actually happened. Did learners only remember terms? Could they apply the idea? Were they confidently wrong? Which question exposed the misconception? Quizotic is my attempt to combine those two jobs in one free platform: live quizzes, interactive presentations, AI question generation, 19 slide types, realtime leaderboards, and reports grounded in Bloom's Taxonomy. This is a look at how I built it with Next.js 15, TypeScript, Prisma, PostgreSQL on Railway, Socket.IO/WebSockets, and a small learning-science layer that sits inside the quiz model instead of being bolted on at the end. Most quiz tools optimize for energy. That is useful. A fast leaderboard can turn a quiet classroom into a competitive one in seconds. But after the session ends, many tools leave the teacher with a shallow artifact: a score, a percentage, and maybe a question-by-question breakdown. That tells you who won. It does not always tell you what kind of thinking was tested. For a trainer, that gap matters. A compliance session where everyone scores 90% on recall questions is not the same as a session where people can apply a policy to a messy case. A math quiz full of formula recognition is not the same as one that asks students to choose a method, explain a pattern, or evaluate an approach. That is why Quizotic treats every question as both an interaction and a diagnostic object. A question can be scored, timed, tagged, explained, and later included in reports. A deck can mix a normal presentation slide with polls, word clouds, open-ended responses, ranking, Q&A, and quiz questions. The host still gets the simple live-room experience, but the data model keeps enough structure to produce useful follow-up. The product goal became: Quizotic is a Next.js App Router application with a custom Node server for realtime sessions. The UI, dashboard, API routes, auth flow, reports, and content pages live in Next.js. The live quiz room runs through Socket.IO so host and participant clients can exchange events with low latency. The high-level shape looks like this: Host browser ----\ +-- Next.js app routes and React UI Participant ------/ Host browser ----\ +-- Socket.IO rooms: session:{code}, host:{code} Participant ------/ Socket server - Prisma - PostgreSQL on Railway The database model is intentionally boring. Quizzes and presentations store flexible JSON content because slide and question types evolve quickly. Sessions, attendees, and answers are relational because they need auditability, reports, and deduplication. One important design choice was adding immutable quiz versions. If a host runs a quiz on Monday and edits the same quiz on Tuesday, Monday's report should still show the exact questions participants saw. model Quiz { id String @id @default cuid title String questions Json versions QuizVersion sessions GameSession } model QuizVersion { id String @id @default cuid quizId String? title String snapshot Json questionCount Int @default 0 createdAt DateTime @default now } model GameSession { id String @id @default cuid code String @unique quizVersionId String? status String @default "waiting" results Json? attendees Attendee answers Answer } The realtime layer keeps active session state in memory for speed, then persists the critical events. Every answer is also written to the Answer table with a uniqueness constraint over session, participant, and question index. That gives the client room to retry safely when the network is weak. model Answer { id String @id @default cuid sessionId String participantId String questionIndex Int answer Json isCorrect Boolean? points Int @default 0 timeMs Int @default 0 confidence String? @@unique sessionId, participantId, questionIndex } Socket payloads are validated with Zod before they touch session state. This matters more than it sounds. In a live quiz, a bad event can break the room for everyone. js const SubmitAnswerSchema = z.object { gameCode: z.string .min 4 .max 10 , participantId: z.string .uuid .optional , answer: z.union z.string .max 2048 , z.number , z.array z.string .max 10 , z.array z.number .max 10 , , timeMs: z.number .int .min -10000 .max 600000 , confidence: z.enum 'sure', 'unsure' .nullable .optional , serverSubmittedAt: z.number .positive .optional , } Deployment is on Railway because the app needs a Node process, a PostgreSQL database, and environment-managed services without a lot of ceremony. The production script runs the custom server, which prepares the Next.js handler and attaches Socket.IO to the same HTTP server. The code also supports a Redis adapter path for horizontal Socket.IO broadcasts when needed. Bloom's Taxonomy is the part that makes Quizotic different from a normal quiz app. In the question model, every question can carry a cognitive level: export type BloomsLevel = | 'remember' | 'understand' | 'apply' | 'analyse' | 'evaluate' | 'create' export interface Question { id: string type: QuestionType text: string correctAnswer?: string explanation?: string bloomsLevel?: BloomsLevel } That tag is small, but it changes the report. A quiz with ten questions is no longer only "8 out of 10 correct." It can also show that seven questions were recall, two were understanding, and only one required application. That gives the host a practical design signal: the next session should probably go deeper. AI generation also uses this structure. Instead of asking the model for "some quiz questions," the app can ask for a balanced set: a few recall questions, some understanding questions, and application or analysis questions where the source material supports them. The host can still edit everything before launching. Reports carry the Bloom level forward with question stats: interface QuestionStat { index: number text: string correctPct: number | null confidenceGrid: ConfidenceGrid | null bloomsLevel: BloomsLevel | null explanation: string | null } The useful part is combining Bloom with confidence. After answering, participants can mark whether they were sure or unsure. That creates a simple confidence grid: interface ConfidenceGrid { sureCorrect: number sureWrong: number unsureCorrect: number unsureWrong: number } "Sure and wrong" is often more actionable than "wrong." It points to a misconception, not just a miss. "Unsure and correct" points to fragile knowledge. When those buckets are grouped by question and cognitive level, the teacher gets a better follow-up map than a raw leaderboard can provide. The leaderboard sounds like the easy part until you build it for real rooms. Answers arrive at different times. Participants disconnect and reconnect. Some question types are scored, while polls, word clouds, open-ended responses, Q&A, rating, drawing, and some ranking interactions are not. Competitive mode needs speed scoring and streaks, while reflection mode should avoid turning the session into a race. The scoring function is deliberately simple: function calcPoints base: number, timeMs: number, timerSeconds: number { const maxMs = timerSeconds 1000 const speedRatio = Math.max 0, 1 - timeMs / maxMs return Math.round base 0.5 + 0.5 speedRatio } A correct answer earns between half and full base points depending on speed. Wrong answers earn zero. Streak bonuses add a little drama, but the system still stays understandable to the host. After a question ends, the server builds a compact leaderboard snapshot and emits it to the room: function buildLeaderboardSnapshot participants: Map