The most inefficient way to study for a certification exam is to review everything at the same rate — spending equal time on topics you've mastered and topics that are quietly undermining your score. Most study apps don't solve this problem. They present questions in a fixed order and leave it to you to figure out where you're weak.
This tutorial builds something better: a Rork app that tracks every answer, uses Gemini to identify your weak categories, and automatically prioritizes those topics the next time you open the app. By the end, you'll have a working adaptive learning app you can publish on the App Store.
Here's what we're building:
- Category-based multiple-choice quizzes
- Automatic weakness detection via Gemini API
- Priority question selection based on your performance data
- Visual progress analytics
- Supabase for persistent answer history
This assumes you've shipped at least one Rork app before. If you're brand new to Rork, start with the beginner guide and come back — the Supabase and Gemini integrations here will make more sense with that foundation.
Designing the Data Model First
Before writing a single prompt in Rork, it's worth spending five minutes on the data model. Adaptive learning lives or dies on the quality of data you collect, and fixing a bad schema mid-build is painful.
Here's the structure we'll use:
users — user accounts (Supabase Auth)
questions — question bank (category, options, correct answer, explanation)
answer_logs — every answer with timestamp and time spent
weak_analysis — AI-generated weakness reports (refreshed every 10 questions)
Create these tables in your Supabase project with this SQL:
CREATE TABLE questions (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
category TEXT NOT NULL,
difficulty INTEGER DEFAULT 1, -- 1: easy, 2: medium, 3: hard
question_text TEXT NOT NULL,
options JSONB NOT NULL, -- ["Option A", "Option B", "Option C", "Option D"]
correct_index INTEGER NOT NULL, -- 0–3
explanation TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE answer_logs (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID NOT NULL,
question_id UUID REFERENCES questions(id),
selected_index INTEGER NOT NULL,
is_correct BOOLEAN NOT NULL,
time_spent_seconds INTEGER,
answered_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE weak_analysis (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID NOT NULL,
analyzed_at TIMESTAMPTZ DEFAULT NOW(),
weak_categories JSONB, -- [{"category": "Networking", "accuracy": 0.4}]
ai_suggestion TEXT, -- Gemini-generated study advice
next_question_ids UUID[] -- IDs to prioritize next session
);Enable Row Level Security so users only see their own data:
ALTER TABLE answer_logs ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users access own logs"
ON answer_logs FOR ALL
USING (user_id = auth.uid());Setting Up the Rork Project
With the database ready, start a new Rork project. Give it this initial prompt to establish the app structure:
Build a React Native certification exam prep app.
Screens needed:
- Home: today's study goal, weakness summary card, start quiz button
- Quiz: one multiple-choice question at a time, answer feedback, explanation modal
- Analysis: category accuracy bar chart, AI advice card, "Focus on weak areas" button
- Settings: target exam date, daily question goal
Use Supabase for data and Gemini API for AI analysis.
Clean white UI with a sense of progress and accomplishment.
Rork will generate the scaffolding. Don't try to get everything right in one pass — getting the structure right first, then refining screen by screen, produces cleaner results than cramming all logic into the first prompt.
Smart Question Selection
The default code Rork generates for question selection is usually a naive SELECT * LIMIT 1. This works but ignores everything we know about the user's performance. Replace it with this:
// src/lib/questionSelector.ts
import { supabase } from './supabase';
export async function getNextQuestion(userId: string) {
// Check whether we have a fresh weakness analysis
const { data: analysis } = await supabase
.from('weak_analysis')
.select('next_question_ids, analyzed_at')
.eq('user_id', userId)
.order('analyzed_at', { ascending: false })
.limit(1)
.single();
const analysisAge = analysis?.analyzed_at
? Date.now() - new Date(analysis.analyzed_at).getTime()
: Infinity;
const recentlyAnswered = await getRecentlyAnswered(userId);
// Use priority list if analysis is fresh (< 24 hours) and has items
if (analysisAge < 86_400_000 && analysis?.next_question_ids?.length > 0) {
const { data: priorityQ } = await supabase
.from('questions')
.select('*')
.in('id', analysis.next_question_ids)
.not('id', 'in', recentlyAnswered.length > 0 ? `(${recentlyAnswered.join(',')})` : '(null)')
.order('difficulty', { ascending: true })
.limit(1)
.single();
if (priorityQ) return priorityQ;
}
// Fall back to a random question from the full bank
const { data: pool } = await supabase
.from('questions')
.select('*')
.not('id', 'in', recentlyAnswered.length > 0 ? `(${recentlyAnswered.join(',')})` : '(null)')
.limit(20);
if (!pool || pool.length === 0) return null;
return pool[Math.floor(Math.random() * pool.length)];
}
async function getRecentlyAnswered(userId: string): Promise<string[]> {
// Skip questions answered in the last 24 hours to avoid repetition
const cutoff = new Date(Date.now() - 86_400_000).toISOString();
const { data } = await supabase
.from('answer_logs')
.select('question_id')
.eq('user_id', userId)
.gte('answered_at', cutoff);
return data?.map(d => d.question_id) ?? [];
}The 24-hour recency filter is the part Rork won't generate on its own, and it matters more than you'd expect. Without it, the weakness analysis sends users into a loop of the same 20 questions, which kills engagement after the first session.
Connecting Gemini for Weakness Analysis
This is the feature that makes the app genuinely useful. Every 10 answered questions, we send a performance summary to Gemini and ask it to identify focus areas.
// src/lib/weaknessAnalyzer.ts
const GEMINI_KEY = process.env.EXPO_PUBLIC_GEMINI_API_KEY; // YOUR_GEMINI_API_KEY
interface AnswerLog {
question: { category: string };
is_correct: boolean;
time_spent_seconds: number;
}
export async function analyzeAndSave(
userId: string,
logs: AnswerLog[]
): Promise<void> {
// Aggregate stats per category
const stats: Record<string, { correct: number; total: number; totalTime: number }> = {};
for (const log of logs) {
const cat = log.question.category;
if (!stats[cat]) stats[cat] = { correct: 0, total: 0, totalTime: 0 };
stats[cat].total++;
if (log.is_correct) stats[cat].correct++;
stats[cat].totalTime += log.time_spent_seconds;
}
const statsText = Object.entries(stats)
.map(([cat, s]) => {
const pct = ((s.correct / s.total) * 100).toFixed(0);
const avg = (s.totalTime / s.total).toFixed(0);
return `${cat}: ${pct}% accuracy, avg ${avg}s per question`;
})
.join('\n');
// Ask Gemini for structured analysis
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GEMINI_KEY}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: `
Analyze this exam prep performance data and return structured JSON.
Performance by category:
${statsText}
Return exactly this JSON structure:
{
"weak_categories": [
{"category": "name", "accuracy": 0.0–1.0}
],
"focus_categories": ["top 1–3 categories to prioritize"],
"advice": "One actionable study tip in 20 words or fewer"
}
` }] }],
generationConfig: {
responseMimeType: 'application/json',
temperature: 0.2
}
})
}
);
if (!res.ok) throw new Error(`Gemini error: ${res.status}`);
const json = await res.json();
const analysis = JSON.parse(json.candidates[0].content.parts[0].text);
// Fetch question IDs for the focus categories
const { data: priorityQs } = await supabase
.from('questions')
.select('id')
.in('category', analysis.focus_categories)
.order('difficulty', { ascending: true })
.limit(30);
// Persist the analysis
await supabase.from('weak_analysis').insert({
user_id: userId,
weak_categories: analysis.weak_categories,
ai_suggestion: analysis.advice,
next_question_ids: priorityQs?.map(q => q.id) ?? []
});
}Two things worth noting here. First, responseMimeType: 'application/json' tells Gemini to return raw JSON — without it, you sometimes get JSON wrapped in Markdown code fences, which breaks JSON.parse. Second, temperature: 0.2 keeps the analysis consistent. Higher temperatures produce more creative but less reliable structured output.
Building the Analytics Screen
Tell Rork what you want the analysis screen to show:
Build AnalysisScreen.tsx with these elements:
1. Stats header: total questions answered, overall accuracy percentage
2. Category bar chart: one bar per category, color-coded
- Under 50%: red (#EF4444)
- 50–75%: amber (#F59E0B)
- Over 75%: green (#22C55E)
3. AI advice card: white card with lightbulb icon, Gemini's advice text
4. "Practice weak areas now" CTA button
5. 7-day streak calendar at the bottom
Data comes from Supabase answer_logs and weak_analysis tables.
The color-coded bars rarely come out right on the first pass — Rork tends to use a single color. Add this as a follow-up prompt:
Update the category bars to animate from gray to their target color
when the screen loads. Use a 300ms transition.
The color should be computed from the accuracy value, not hardcoded.
Monetization: What to Gate
For a study app, the freemium split I've found most effective is:
Free tier: 10 questions per day, basic correct/wrong feedback, category accuracy totals.
Paid tier (around $4/month): unlimited questions, AI weakness analysis, difficulty auto-adjustment, push notification reminders.
Gating the AI analysis behind a paywall isn't just a business decision — it's cost management. Gemini API calls add up when your user base grows, and free users burning API budget on every 10-question interval doesn't scale. Limiting AI analysis to paying users keeps your margin positive from day one.
Wire up the paywall check before calling analyzeAndSave:
// Before triggering analysis
const { data: subscription } = await supabase
.from('subscriptions')
.select('status')
.eq('user_id', userId)
.single();
if (subscription?.status === 'active') {
await analyzeAndSave(userId, recentLogs);
} else {
// Show a gentle upsell — don't hard block, just tease
showUpgradePrompt('Unlock AI analysis to see exactly where you need to improve');
}Generating Your Question Bank
A functional app without questions isn't publishable. Use this script to batch-generate questions with Gemini before launch:
// scripts/seedQuestions.ts — run locally, not in the app
const CATEGORIES = ['Networking', 'Security', 'Databases', 'Algorithms'];
for (const category of CATEGORIES) {
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent?key=${GEMINI_KEY}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: `
Generate 10 multiple-choice questions for a professional certification exam.
Category: ${category}
Mix of difficulty levels 1–3.
Return JSON array:
[{
"category": "${category}",
"difficulty": 1–3,
"question_text": "...",
"options": ["A", "B", "C", "D"],
"correct_index": 0–3,
"explanation": "Brief explanation under 80 words"
}]
` }] }],
generationConfig: { responseMimeType: 'application/json' }
})
}
);
const data = await res.json();
const questions = JSON.parse(data.candidates[0].content.parts[0].text);
await supabase.from('questions').insert(questions);
console.log(`Inserted ${questions.length} ${category} questions`);
}Before importing, manually review every question. AI-generated exam content occasionally has ambiguous wording, incorrect answers, or explanations that contradict the question. Catching these before users see them saves you bad reviews.
What to Build Next
The app you have now is functional and publishable. To take it further, the highest-impact next feature is spaced repetition scheduling — using the SM-2 algorithm to calculate optimal review intervals for each question based on how many times you've gotten it right and wrong. It turns the current "recent answers" filter into something scientifically grounded.
For now, ship what you have. Even a simple adaptive quiz with AI analysis is meaningfully better than a static flashcard app, and getting real user feedback will tell you which direction to push next.
You can read more about Rork + Gemini integration patterns and setting up Supabase authentication in Rork apps to build on what you've started here.