DEV Community

Cover image for Building Prepzilla: How I Made CEED Exam Practice Free for Everyone (And What I Learned)
Ritik Jangir
Ritik Jangir

Posted on

Building Prepzilla: How I Made CEED Exam Practice Free for Everyone (And What I Learned)

Why Education Should Be Accessible

When I started building Prepzilla, my motivation was simple: education should be free, or at least accessible. Not everyone can afford the hefty fees of coaching centers, yet those who do gain a significant advantage. This creates an unfair playing field for design entrance exam aspirants preparing for CEED (Common Entrance Exam for Design).

I wanted to change that.

What started as a weekend project sprouting from my own need to practice CEED questions quickly evolved into something much bigger. Within 24 hours of sharing it on Reddit (thanks to a shoutout from the r/designForge community), nearly 100 users had signed up and started practicing. The response was overwhelming.

But more importantly, it validated something I'd long suspected: there's a massive need for free, quality educational tools.

Once I saw how quickly it resonated, I decided to document how it was built - both for transparency and for anyone curious about the technical process behind educational tools. Because this is also a first for me: I'm building for users with users. Previously, I'd only built in isolation for clients and product managers. Now I have real people giving me transparent feedback, reporting bugs, suggesting features, and actively shaping the platform's future.

Let's see how this shapes up.


Design Philosophy: Constraints as Features

When building an educational tool, especially one aimed at exam preparation, the philosophy is different from building a typical web app. The interface needs to get out of the way.

My Core Principles

  1. Question-first interface: No distractions. The question is the hero.
  2. Functional over flashy: Clean UI beats animations every time and the interface colors chosen were purposefully muted.
  3. Speed matters: Fast load times mean more time for practice.
  4. Accessibility: Work on any device, any screen size, any internet speed.
  5. Similarity: Questions should look similar to the actual exam.
  6. Analytics: Track performance and identify personalized patterns.
  7. Flexibility: Allow users to practice in their own time.

These weren't lofty ideals—they were born from constraints:

  • Budget: ₹0 for infrastructure (using Supabase free tier)
  • Time: Built in nights and weekends
  • Experience: This was my learning ground for production-grade analytics (maybe)

The result? A minimalist, fast, distraction-free platform that does one thing well: help students practice CEED Part A questions effectively.

Why Radix UI Over Others

I'm not a huge fan of component libraries that lock you into specific design patterns, but I needed something that:

  • Gave me unstyled, accessible primitives (Radix UI)
  • Let me work on business layer and service layer of the app and not be worried about UI
  • Worked seamlessly with Next.js 16 and React 19

I deliberately avoided Zustand and React Query in v1. Not because they're bad-they're excellent—but because I wanted to:

  1. Only add complexity when actually needed (not preemptively)
  2. I am not sure about the shape of the store yet.

Turns out, I did need better data fetching (more on that later), but starting simple helped me understand why I needed it.


Technical Breakdown: From Raw PDFs to Production

1. Data Structuring: The PDF Hell

This was by far the hardest part.

CEED past papers are PDFs with:

  • Embedded images (diagrams, spatial reasoning questions)
  • Text as images incorrectly rendered
  • Complex layouts
  • Inconsistent formatting across years

My initial approach: Automate everything with a script.

Reality: Partial success.

Attempt 1: Direct PDF Text Extraction

// My naive first attempt
// Extract text + images → Structure questions → Upload to supabase → Done! 
// (Spoiler: Didn't work)
Enter fullscreen mode Exit fullscreen mode

Problem: Text came out almost fine but needed parsing to extract questions. Some text were rendered as images. No spatial information. Images were missing.

Attempt 2: PDF.js for Embedded Images

// Try to extract embedded images
// Extract embedded images from PDF, rename them, and upload to Supabase storage
Enter fullscreen mode Exit fullscreen mode

Problem: Only worked for PDFs that were correctly formatted with embedded images. Many CEED papers have text rendered as images. It worked for some papers but not all. For one paper with 46 question images, the script extracted nearly 850 images. (DAYUM -_- they were all text rendered as images)

Attempt 3: OCR-Based Approach

I turned to OCR tools (Tesseract.js, Google Cloud Vision API) thinking they'd solve everything.

Problem: OCR introduced errors. Mathematical expressions became gibberish. Diagrams lost context.

Final Solution: Hybrid Manual + Smart Tooling

I gave up on full automation and used a pragmatic approach:

  1. Online PDF extraction tools to separate text and image layers
  2. Manual verification of each question for accuracy
  3. Automatic categorization of questions by topic based on keywords in the question text (took claude's help to build this)
  4. Structured schema to store everything consistently
  5. Question uploader script to label questions by topic
// categorize.js - My manual categorization helper
const TOPICS = {
  1: "Visualization and Spatial Reasoning",
  2: "Practical and Scientific Knowledge",
  3: "Observation and Design Sensitivity",
  4: "Environment and Society",
  5: "Analytical and Logical Reasoning",
  6: "Language",
  7: "Creativity",
  8: "Art and Design Knowledge",
  9: "Design Methods and Practices",
};

// For each question, I manually assigned:
  {
    "question_number": 7,
    "year": 2016,
    "section": "NAT",
    "question_type": "NAT",
    "topic_id": "a1111111-1111-1111-1111-111111111111",
    "question_text": "A cube of size 10 cm x 10 cm x 10 cm is taken and 2 cm x 2 cm square shaped tunnels are cut through the centres of opposite faces of the cube. Count the number of surfaces in the resulting object.",
    "correct_answer": "30",
    "marks": 2,
    "negative_marks": -0.6,
    "difficulty": "Hard",
    "options": []
  },
Enter fullscreen mode Exit fullscreen mode

Yes, this took hours. But it resulted in clean, reliable data that I could confidently build on.

Lesson learned: Sometimes the "boring" manual work is the only way to ensure quality. Automation is great, but not at the cost of accuracy. And I need to upskill my automation script writing skills.


2. Database Schema: Designing for Analytics from Day One

I knew analytics would be crucial, so I structured the database to support it from the start.

Core Tables

-- Questions with rich metadata
CREATE TABLE questions (
  id UUID PRIMARY KEY,
  topic_id UUID REFERENCES topics(id),
  question_text TEXT NOT NULL,
  question_type TEXT CHECK (question_type IN ('MCQ', 'MSQ', 'NAT')),
  section TEXT CHECK (section IN ('NAT', 'MSQ', 'MCQ')),
  correct_answer TEXT NOT NULL,
  marks INTEGER DEFAULT 3,
  negative_marks NUMERIC DEFAULT 0,
  year INTEGER NOT NULL,
  difficulty TEXT CHECK (difficulty IN ('Easy', 'Medium', 'Hard'))
);

-- Test attempts with metadata
CREATE TABLE attempts (
  id UUID PRIMARY KEY,
  user_id UUID REFERENCES user_profiles(id),
  total_questions INTEGER NOT NULL,
  correct_answers INTEGER NOT NULL,
  score NUMERIC NOT NULL,
  duration_seconds INTEGER,
  started_at TIMESTAMP DEFAULT NOW(),
  completed_at TIMESTAMP
);

-- Per-question analytics
CREATE TABLE attempt_answers (
  id UUID PRIMARY KEY,
  attempt_id UUID REFERENCES attempts(id),
  question_id UUID REFERENCES questions(id),
  user_answer TEXT,
  is_correct BOOLEAN,
  marks_obtained NUMERIC,
  time_spent_seconds INTEGER, -- ⚠️ This was crucial
  selected_options ARRAY,      -- For MSQ tracking
  skipped BOOLEAN DEFAULT false
);
Enter fullscreen mode Exit fullscreen mode

Key insight: The time_spent_seconds and selected_options fields were afterthoughts that became game-changers for analytics.

They enabled:

  • Time allocation analysis (which sections take too long?)
  • MSQ strategy tracking (how many options do users typically select?)
  • Rushed vs. stuck questions (identifying patterns)

3. The Infamous N+1 Problem

Early on, I made a classic mistake that haunts many developers: the N+1 query problem.

The Problem

When loading a test with 44 questions (CEED's standard format), my code was doing:

// ❌ BAD: N+1 queries
const questions = await fetchQuestions();

for (const question of questions) {
  // Fetching options for EACH question = N queries
  const options = await supabase
    .from('question_options')
    .select('*')
    .eq('question_id', question.id);
}
Enter fullscreen mode Exit fullscreen mode

Result: 44+ database queries just to load one test. Terrible performance.

The Solution

Batch fetch everything in one query:

// ✅ GOOD: Single query for all options
const questionIds = questions.map(q => q.id);

const { data: allOptions } = await supabase
  .from('question_options')
  .select('*')
  .in('question_id', questionIds)
  .order('option_key', { ascending: true });

// Group by question_id client-side
const optionsMap = allOptions.reduce((acc, option) => {
  if (!acc[option.question_id]) acc[option.question_id] = [];
  acc[option.question_id].push(option);
  return acc;
}, {});
Enter fullscreen mode Exit fullscreen mode

Impact: Reduced test loading time from ~9 seconds to ~300ms.

Lesson: Always think about database query patterns. Modern ORMs make it easy to accidentally create N+1 problems.

Before you google what N+1 problem is

Analogy
Ordering pizza:

N+1 version = you call the restaurant once per topping.

Optimized version = you tell them all toppings in one order.
Both achieve the same thing, but the second avoids wasted trips.


4. Architecture: Component Structure

I structured the app around separation of concerns:

app/
├── test/
│   ├── create/         # Test configuration
│   ├── session/        # Active test interface
│   └── results/        # Post-test analytics
├── dashboard/          # User overview
├── admin/              # Question management
└── auth/               # Authentication flows

components/
├── test/
│   ├── question-content.tsx   # Question display
│   ├── navigation-buttons.tsx # Prev/Next/Submit
│   └── question-palette.tsx   # Visual navigation
└── analytics/
    ├── section-performance.tsx
    ├── msq-strategy.tsx
    └── marks-lost-breakdown.tsx

hooks/
├── use-test-session.ts      # State management
├── use-test-navigation.ts   # Question switching
├── use-test-submission.ts   # Scoring & saving
└── useAnalytics.ts          # Analytics calculations
Enter fullscreen mode Exit fullscreen mode

Custom Hooks for Clean Separation

Instead of a massive component with 500+ lines, I split logic into focused hooks:

// hooks/use-test-navigation.ts
export function useTestNavigation(questions) {
  const [currentIndex, setCurrentIndex] = useState(0);
  const [answers, setAnswers] = useState({});
  const [visitedQuestions, setVisitedQuestions] = useState(new Set());
  const [questionTimes, setQuestionTimes] = useState({});

  const handleAnswerChange = (questionId, answer) => {
    setAnswers(prev => ({ ...prev, [questionId]: answer }));
  };

  const handleQuestionChange = (newIndex) => {
    // Track time spent on current question
    trackTime(questions[currentIndex].id);
    setCurrentIndex(newIndex);
    setVisitedQuestions(prev => new Set([...prev, newIndex]));
  };

  return {
    currentIndex,
    answers,
    visitedQuestions,
    questionTimes,
    handleAnswerChange,
    handleQuestionChange,
  };
}
Enter fullscreen mode Exit fullscreen mode

This made the main test component clean and readable:

export default function TestSessionPage() {
  const { questions, options, isLoading } = useTestSession();
  const { currentIndex, answers, handleAnswerChange } = useTestNavigation(questions);
  const { handleSubmitTest } = useTestSubmission();

  if (isLoading) return <TestPageSkeleton />;

  return (
    <div>
      <QuestionContent 
        question={questions[currentIndex]}
        answer={answers[questions[currentIndex].id]}
        onAnswerChange={handleAnswerChange}
      />
      <NavigationButtons onSubmit={handleSubmitTest} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

5. Analytics Layer: Making Data Actionable

Raw numbers are boring. Insights are valuable.

I built an analytics system that doesn't just show "You got 65%" but tells you why and how to improve.

Section Performance Breakdown

// hooks/useAnalytics.ts
async function processSectionPerformance(answers, questionInfoMap) {
  const sections = ['NAT', 'MSQ', 'MCQ'];
  const performance = [];

  for (const section of sections) {
    const sectionAnswers = answers.filter(a => 
      questionInfoMap.get(a.question_id)?.section === section
    );

    const correct = sectionAnswers.filter(a => a.is_correct).length;
    const attempted = sectionAnswers.filter(a => a.user_answer).length;
    const avgTimeSeconds = sectionAnswers.reduce(
      (sum, a) => sum + (a.time_spent_seconds || 0), 0
    ) / attempted;

    performance.push({
      section,
      attempted,
      correct,
      accuracy: (correct / attempted) * 100,
      avgTimeSeconds,
      marksObtained: sectionAnswers.reduce(
        (sum, a) => sum + parseFloat(a.marks_obtained || 0), 0
      ),
    });
  }

  return performance;
}
Enter fullscreen mode Exit fullscreen mode

MSQ Strategy Analysis

One of my favorite features: tracking how many options users select in MSQ questions.

async function processMSQStrategy(answers, questionInfoMap) {
  const msqAnswers = answers.filter(a =>
    questionInfoMap.get(a.question_id)?.section === 'MSQ'
  );

  const strategies = {
    oneOption: { count: 0, totalMarks: 0, accuracy: 0 },
    twoOptions: { count: 0, totalMarks: 0, accuracy: 0 },
    threeOptions: { count: 0, totalMarks: 0, accuracy: 0 },
    fourOptions: { count: 0, totalMarks: 0, accuracy: 0 },
  };

  for (const answer of msqAnswers) {
    const optionCount = answer.user_answer
      ? answer.user_answer.split(',').length
      : 0;

    // Categorize by strategy
    if (optionCount === 1) strategies.oneOption.count++;
    // ... similar for 2, 3, 4 options
  }

  // Generate recommendation
  let recommendation = "Keep practicing MSQ questions.";
  if (strategies.twoOptions.accuracy > strategies.oneOption.accuracy) {
    recommendation = "Great! Your 2-option strategy is effective.";
  }

  return strategies;
}
Enter fullscreen mode Exit fullscreen mode

This tells users: "Hey, you're selecting 4 options too often and losing marks. Try being more conservative."


6. Tech Stack Decisions

What I Used

  • Next.js 16: For SSR, routing, and deployment
  • TypeScript: Type safety across the board
  • Supabase: PostgreSQL database + auth + RLS
  • Tailwind CSS: Utility-first styling
  • Radix UI: Headless accessible components

What I Deliberately Avoided (For Now)

  • Zustand: React's useState + useContext was enough for v1
  • React Query: Built custom hooks first; will add when caching becomes painful
  • Prisma: Supabase client was sufficient

Why this matters: Starting simple let me ship fast. I'll add complexity only when the pain points justify it.


Database Schema Visualization

database schema

┌─────────────┐       ┌─────────────────┐       ┌──────────────────┐
│   topics    │◄──────┤    questions    │◄──────┤ question_options │
└─────────────┘       └─────────────────┘       └──────────────────┘
                             │ ▲                         │
                             │ │                         │
                             │ │                         │
                             ▼ │                         ▼
                      ┌───────────────┐            (MCQ/MSQ only)
                      │question_topics│
                      └───────────────┘
                             │
                             │
                             ▼
┌──────────────┐      ┌─────────────┐      ┌──────────────────┐
│user_profiles │◄─────┤  attempts   │◄─────┤ attempt_answers  │
└──────────────┘      └─────────────┘      └──────────────────┘
      │                     │                      │
      │                     │                      ├─► time_spent_seconds
      │                     │                      ├─► marks_obtained
      │                     │                      ├─► selected_options
      │                     │                      └─► skipped
      │                     │
      │                     ▼
      │              ┌─────────────────┐
      │              │ attempt_topics  │
      │              └─────────────────┘
      │
      │
      │        ┌──────────────────┐
      ├──────► │ question_reports │ (User feedback on questions)
      │        └──────────────────┘
      │
      └──────► ┌──────────────────┐
               │general_feedback  │ (Bug reports, feature requests)
               └──────────────────┘
Enter fullscreen mode Exit fullscreen mode

Reflection: Building With Users, Not Just For Them

This project is a first for me in a crucial way.

Previously, I'd built tools in isolation:

  1. Come up with an idea
  2. Build it alone
  3. Release it
  4. Hope people use it
  5. (Usually crickets)

This time was different.

Within 24 hours of sharing on Reddit:

  • ~100 users signed up
  • Couple of bug reports came in (caught edge cases I missed)
  • Feature requests started flooding in
  • Transparent feedback from real CEED aspirants

The Feedback Loop

One user reported: "The timer doesn't pause when I switch tabs!"

I initially thought: "That's intentional - it's a real exam simulation."

But then multiple users said the same thing. Turns out, many students practice on mobile and frequently get interruptions (calls, messages). Making the timer strict was hurting the experience.

The fix: Added a warning when switching tabs, but didn't pause (compromise).

Another user: "Can we have a practice mode without the timer?"

It's now at the mid of my roadmap.

What I'm Learning

Building with users means:

  • Rapid iteration based on real pain points
  • Prioritization becomes clearer (what users actually want vs. what I think they want)
  • Motivation stays high (people are using this!)
  • Accountability (bugs affect real people, not just me)

It's exciting but also humbling. I'm learning that my assumptions about what users need are often wrong.


Challenges & Lessons

1. The journey of PDF Data Extraction to Structured Data is Brutal

Lesson: Don't underestimate the difficulty of working with unstructured data. Automation sounds nice, but quality > speed.

2. Performance Matters More Than Features

Lesson: A fast, simple app beats a slow, feature-rich one every time. Optimize early (but not prematurely).

3. Analytics Need Context

Lesson: Raw numbers don't help. "You scored 65%" is useless. "You're rushing MCQs (avg 45s) but stuck on NAT (avg 150s). Try allocating time better." is actionable.

4. Users Find Bugs You Never Imagined

Lesson: Real users stress-test your app in ways you never will. Embrace feedback.

5. Constraints Force Creativity

Lesson: Having zero budget forced me to use free tiers creatively. Supabase's free tier is surprisingly generous for small-scale apps.


Outcomes & Next Steps

Current Stats (Day 3)

  • ~100 users signed up
  • 600+ questions added
  • 10 tests added
  • Active feedback loop (through DesignForge WhatsApp and in-app feedback tables)
  • ₹0 cost

Wins

✅ Fast UI, accurate scoring, deep analytics, mobile-ready, free.

What Needs Work

⚠️ Practice mode (without timer)

⚠️ Question bookmarking
⚠️ More detailed performance history
⚠️ Performance history per attempt
⚠️ Retry attempts feature
⚠️ Community features (discussion forums?)
⚠️ ANSWER EXPLANATIONS

Roadmap

Phase 1: Core Improvements (Next Week)

  • [ ] Add practice mode (untimed, instant feedback)
  • [ ] Implement question bookmarking
  • [ ] Add explanation to answers (Still wondering how to do it, if any ideas drop them in the comments)
  • [ ] Optimize for React Query (caching)

Phase 2: Community Features (Next Month)

  • [ ] Add discussion forums per question
  • [ ] Peer comparison (anonymized)
  • [ ] Study groups / challenges
  • [ ] Weekly leaderboards

Phase 3: Expansion (Month 3+)

  • [ ] Add UCEED (Undergraduate CEED)
  • [ ] Add NID entrance prep
  • [ ] Gamification (streaks, badges)

For Other Builders: Key Takeaways

If you're building an educational tool (or any product), here's what I'd recommend:

1. Start with the Problem, Not the Tech

I didn't set out to "build with React 19" or "try Supabase." I wanted to solve a specific problem: make CEED practice accessible.

The tech stack followed from that goal.

2. Ship Fast, Iterate Faster

My first version was rough (I've tried building such a platform before). Timer bugs, UI glitches, missing features. But i survived and grew, and that's what mattered.

Shipping fast meant getting feedback fast.

3. Analytics Are Your Product's Teacher

Don't just track "users signed up." Track:

  • Where do users drop off?
  • Which features are actually used?
  • What errors occur most often?

This data tells you what to build next.

4. Manual Work Isn't Failure

I spent hours manually structuring question data. It felt like a waste of time.

But that manual work created a high-quality dataset that automation would've ruined.

5. Users Will Surprise You

You think you know what they need. You don't.

Build, ship, listen, adapt.


Conclusion: Education as a Right, Not a Privilege

Building Prepzilla has been fun, not because of the tech challenges, but because it's making a real difference.

Nearly 100 students used it in the first 24 hours. That's 100 people who now have access to quality CEED practice for free.

And this is just the beginning.

If you're preparing for CEED, give it a try: prepzilla.artelia.co.in

If you're a builder, I hope this article inspires you to:

  1. Build something that matters
  2. Ship before it's perfect
  3. Listen to your users
  4. Iterate relentlessly

And if you believe education should be accessible, help spread the word. Share this tool with anyone preparing for design entrance exams.


Connect & Contribute

If you're a CEED aspirant, developer, or just someone who believes in accessible education, I'd love to hear from you:

Special thanks to the r/designForge community for the initial shoutout and support. You gave this project wings.


Building for users, with users. Let's make learning accessible.


Tags: #Education #WebDev #React #Next.js #OpenSource #CEED #DesignEducation #EdTech #BuildInPublic

Top comments (0)