●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Building an AI Personalization Engine with Rork Max — Adaptive UI, Smart Content Ranking, and Notification Timing That Learns from Every User
An advanced guide to building an AI-powered personalization engine in Rork Max. Learn how to collect user behavior data, build real-time learning pipelines, dynamically optimize UI layouts and content delivery, and automate notification timing — with the production lessons I learned running wallpaper and calming-tone apps solo for over a decade.
Making People Feel the App Was Built Just for Them
Thousands of new apps hit the app stores every day. When feature parity makes differentiation nearly impossible, the apps that win are the ones that make every user feel like the experience was built just for them. That feeling — personalization — is now the single biggest driver of retention.
As an indie app developer, I've run small wallpaper, calming-tone, and intention apps solo for years. Now that features alone rarely set an app apart, the thing I find moves retention most is a small, tactile sense that the app was prepared for you. What once needed teams of data scientists is now within reach for a single developer when you pair Rork Max with AI APIs. This guide walks the design — from learning behavior to adapting UI layout, content order, and notification timing — and then shares, concretely, the mistakes I hit in production and how I avoided them.
This article walks you through the complete architecture: data collection, AI-powered analysis, dynamic UI adaptation, smart content ranking, and intelligent notification scheduling. It's an advanced guide — you should be comfortable with Rork Max basics, React Native state management, and Supabase fundamentals before diving in. If you're just getting started, check out the Rork AI Prompt Engineering Mastery Guide first.
Architecting the Personalization Engine
The AI personalization engine consists of four distinct layers, each with a clear responsibility.
Data Collection Layer (Event Tracking)
Every meaningful user action becomes an event: taps, scrolls, screen dwell time, search queries, feature usage frequency. This raw behavioral data feeds the learning pipeline.
Intelligence Layer (Analysis & Learning)
Collected data flows into an analysis pipeline where AI (Claude API or Gemini API) extracts behavioral patterns that rule-based systems can't detect. This layer builds and continuously updates user preference profiles.
Decision Layer
Based on learning results, this layer determines what each user should see. It decides UI component ordering, content priorities, and notification timing and content — all the personalization logic converges here.
Delivery Layer
Decisions get applied to the app UI in real time, integrating with React Native's state management to deliver personalized experiences without visual glitches or layout shifts.
// Core structure of the personalization engine// personalization-engine.tsimport { createClient } from '@supabase/supabase-js';// Type definitionsinterface UserProfile { userId: string; segments: string[]; // User segments preferences: Record<string, number>; // Preference scores engagementPattern: { peakHours: number[]; // Active hours avgSessionDuration: number; // Average session length favoriteFeatures: string[]; // Most-used features }; lastUpdated: string;}interface PersonalizationDecision { uiLayout: string; // UI layout pattern contentOrder: string[]; // Content display order highlightedFeatures: string[]; // Features to emphasize notificationSchedule: { nextSendTime: string; // Next notification time messageTemplate: string; // Message template ID };}class PersonalizationEngine { private supabase; private cache: Map<string, UserProfile> = new Map(); constructor(supabaseUrl: string, supabaseKey: string) { this.supabase = createClient(supabaseUrl, supabaseKey); } // Get user profile with caching async getUserProfile(userId: string): Promise<UserProfile> { if (this.cache.has(userId)) { const cached = this.cache.get(userId)\!; const age = Date.now() - new Date(cached.lastUpdated).getTime(); if (age < 5 * 60 * 1000) return cached; // 5-minute cache } const { data } = await this.supabase .from('user_profiles') .select('*') .eq('user_id', userId) .single(); if (data) { this.cache.set(userId, data); return data; } // Default profile for new users return this.createDefaultProfile(userId); } // Execute personalization decision async decide(userId: string): Promise<PersonalizationDecision> { const profile = await this.getUserProfile(userId); return this.generateDecision(profile); } private createDefaultProfile(userId: string): UserProfile { return { userId, segments: ['new_user'], preferences: {}, engagementPattern: { peakHours: [], avgSessionDuration: 0, favoriteFeatures: [], }, lastUpdated: new Date().toISOString(), }; } private generateDecision(profile: UserProfile): PersonalizationDecision { return { uiLayout: this.selectLayout(profile), contentOrder: this.rankContent(profile), highlightedFeatures: this.selectFeatures(profile), notificationSchedule: this.scheduleNotification(profile), }; }}export default PersonalizationEngine;
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Why personalizing on day one dropped Day 1 retention from 42% to 34%, and the 3-session hold that recovered it to 44%
✦How calling the AI every session pushed monthly API cost from ¥9,000 to ¥38,000 — and the threshold-batch throttle that fixed it
✦Retiming notifications to the median recent open hour to lift open rate from 4.8% to 9.1%, with the code
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Building the User Behavior Data Collection Pipeline
Personalization accuracy depends directly on data quality and volume. Here's how to build an effective event tracking system while respecting user privacy.
Designing the Event Tracking System
The key principle is selective tracking — record only events that directly feed personalization decisions, not everything.
An efficient table structure for storing and querying behavioral data:
-- User events tableCREATE TABLE user_events ( id BIGSERIAL PRIMARY KEY, user_id UUID NOT NULL REFERENCES auth.users(id), event_name TEXT NOT NULL, category TEXT NOT NULL, properties JSONB DEFAULT '{}', session_id TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW());-- Indexes for query performanceCREATE INDEX idx_events_user_time ON user_events (user_id, created_at DESC);CREATE INDEX idx_events_category ON user_events (category, event_name);-- User profiles table (stores AI analysis results)CREATE TABLE user_profiles ( user_id UUID PRIMARY KEY REFERENCES auth.users(id), segments TEXT[] DEFAULT ARRAY['new_user'], preferences JSONB DEFAULT '{}', engagement_pattern JSONB DEFAULT '{}', personalization_config JSONB DEFAULT '{}', last_updated TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW());-- Row Level SecurityALTER TABLE user_events ENABLE ROW LEVEL SECURITY;ALTER TABLE user_profiles ENABLE ROW LEVEL SECURITY;CREATE POLICY "Users can insert own events" ON user_events FOR INSERT WITH CHECK (auth.uid() = user_id);CREATE POLICY "Users can read own profile" ON user_profiles FOR SELECT USING (auth.uid() = user_id);
Privacy and Data Minimization
When collecting data for personalization, adhere strictly to these principles. Data minimization: only collect what directly serves personalization. If precise location isn't needed, region-level data is sufficient. Transparency: clearly state in your privacy policy what data you collect and why. On iOS, implement the ATT (App Tracking Transparency) framework. Data expiration: regularly archive or purge old event data. Thirty days of behavioral data is typically sufficient for effective personalization.
AI-Powered User Segmentation and Preference Analysis
The intelligence layer transforms raw behavioral data into actionable user profiles. This is the heart of the personalization engine.
Based on AI analysis results, users are automatically classified into segments.
Behavioral segments: power_user (daily usage, 5+ features), casual_user (2-3 times per week, basic features), explorer (actively tries new features), focused_user (concentrates on specific features).
Lifecycle segments: new_user (within 7 days of first launch), activated (used core features 3+ times), retained (14+ days of continuous use), at_risk (usage frequency dropped 50%+), dormant (14+ days inactive).
Preference scores: Each content category receives a 0-to-1 score, calculated as a weighted average of screen dwell time, interaction frequency, and content consumption volume.
Push notification effectiveness depends as much on when you send as what you send. Here's how to predict the optimal notification time for each individual user.
Learning Activity Patterns
// notification-optimizer.tsinterface NotificationPlan { scheduledTime: Date; messageTemplate: string; priority: 'high' | 'medium' | 'low'; channel: 'push' | 'in_app' | 'email';}class NotificationOptimizer { analyzeActivityPattern(events: any[]): number[] { const hourlyActivity = new Array(24).fill(0); for (const event of events) { const hour = new Date(event.created_at).getHours(); hourlyActivity[hour] += 1; } const maxActivity = Math.max(...hourlyActivity, 1); return hourlyActivity.map(count => count / maxActivity); } findOptimalSendTime( activityPattern: number[], preferredWindow: { start: number; end: number } = { start: 8, end: 22 } ): number { let bestHour = 9; let bestScore = 0; for (let hour = preferredWindow.start; hour <= preferredWindow.end; hour++) { const h = hour % 24; // Target the hour just before peak activity const preActivityScore = activityPattern[(h + 1) % 24] || 0; const currentActivity = activityPattern[h] || 0; // Avoid sending when user is already in-app const score = preActivityScore * 0.7 + (1 - currentActivity) * 0.3; if (score > bestScore) { bestScore = score; bestHour = h; } } return bestHour; } async createNotificationPlan( userId: string, userProfile: UserProfile, events: any[] ): Promise<NotificationPlan> { const activityPattern = this.analyzeActivityPattern(events); const optimalHour = this.findOptimalSendTime(activityPattern); const template = this.selectTemplate(userProfile.segments); const now = new Date(); const scheduledTime = new Date(now); scheduledTime.setHours(optimalHour, 0, 0, 0); if (scheduledTime <= now) { scheduledTime.setDate(scheduledTime.getDate() + 1); } return { scheduledTime, messageTemplate: template, priority: this.determinePriority(userProfile), channel: this.selectChannel(userProfile), }; } private selectTemplate(segments: string[]): string { if (segments.includes('at_risk')) return 'miss_you'; if (segments.includes('new_user')) return 'onboarding_tip'; if (segments.includes('power_user')) return 'new_feature'; return 'content_recommendation'; } private determinePriority(profile: UserProfile): 'high' | 'medium' | 'low' { if (profile.segments.includes('at_risk')) return 'high'; if (profile.segments.includes('new_user')) return 'medium'; return 'low'; } private selectChannel(profile: UserProfile): 'push' | 'in_app' | 'email' { if (profile.segments.includes('power_user')) return 'in_app'; if (profile.segments.includes('at_risk')) return 'push'; return 'push'; }}
Intelligent Frequency Control
Over-notifying drives users away. The system automatically adjusts sending frequency based on user response data.
// notification-frequency-controller.tsclass FrequencyController { calculateResponseRate( sentNotifications: any[], openedNotifications: any[] ): number { if (sentNotifications.length === 0) return 0.5; return openedNotifications.length / sentNotifications.length; } determineInterval(responseRate: number, segment: string): number { const baseInterval: Record<string, number> = { power_user: 24, // Once daily activated: 48, // Every 2 days new_user: 72, // Every 3 days at_risk: 168, // Weekly dormant: 336, // Bi-weekly }; const base = baseInterval[segment] || 72; if (responseRate > 0.3) { return Math.max(base * 0.5, 12); // Min 12 hours } else if (responseRate < 0.05) { return Math.min(base * 1.5, 672); // Max 4 weeks } return base; }}
Performance Optimization and Fallback Strategies
A personalization engine must never slow down app rendering. Here's how to maintain performance while delivering personalized experiences.
Accurate measurement is essential for continuous improvement. Here's how to set up the metrics framework.
Defining Key KPIs
Track these metrics to evaluate personalization impact.
Engagement metrics: average session duration (target: +20%), screens per session (target: +15%), content consumption rate (percentage of displayed content that was actually tapped or viewed).
Retention metrics: Day 1 / Day 7 / Day 30 retention rates, comparing personalized vs. non-personalized cohorts.
What the docs won't tell you — lessons from production
Everything above is the design. But once I ran personalization inside my own apps, several things didn't go the way the sample code suggested. As an indie developer, I've run iOS and Android apps for years — mostly quiet, everyday utilities in the wallpaper, calming-tone, and intention space. Across those apps I've added and removed home-screen reordering and notification-timing optimization more times than I can count, and here are the operational details the documentation never mentions.
1. Personalizing on day one actually pushes new users away
This was my first mistake. In one wallpaper app I tried to apply the learned profile from the very first launch. With only two or three events on record, a skewed reordering kicked in and Day 1 retention dropped from 42% to 34%. The culprit is the cold-start problem: while behavioral signals are thin, the inference is just noise.
My current rule is to keep the default layout fixed for the first three sessions and only enable personalization gradually once there are enough signals. After reverting to this, Day 1 climbed back to 44%.
// cold-start-guard.ts — hold off on personalization until signals accumulateconst MIN_SESSIONS_FOR_PERSONALIZATION = 3;const MIN_EVENTS_FOR_PERSONALIZATION = 20;function shouldPersonalize(profile: UserProfile, eventCount: number): boolean { const sessions = profile.engagementPattern.peakHours.length; // rough session-count proxy if (profile.segments.includes('new_user')) { if (eventCount < MIN_EVENTS_FOR_PERSONALIZATION) return false; if (sessions < MIN_SESSIONS_FOR_PERSONALIZATION) return false; } return true;}// caller: if false, return the fixed default layoutconst decision = shouldPersonalize(profile, eventCount) ? await engine.decide(userId) : getDefaultDecision();
2. Calling the AI on every session quietly inflates your bill
Behavioral analysis with the Claude API is powerful, but running it on every launch makes the cost stack up. I let this slip and only later noticed one month's API spend had ballooned to nearly four times normal — from around ¥9,000 to ¥38,000.
The fix is simple: run AI analysis on an event threshold or a once-a-day batch, not in real time. Do the UI reordering every time with lightweight rule-based scoring, and reserve the heavy AI inference for profile updates. Costs returned to baseline and the perceived accuracy barely changed.
// ai-analysis-throttle.ts — thin out how often the AI runsconst ANALYSIS_COOLDOWN_MS = 24 * 60 * 60 * 1000; // 24 hoursconst EVENTS_TRIGGER_THRESHOLD = 80; // re-analyze after 80 new eventsfunction shouldRunAIAnalysis( lastAnalyzedAt: string | null, newEventCount: number): boolean { if (newEventCount >= EVENTS_TRIGGER_THRESHOLD) return true; // follow sudden shifts immediately if (!lastAnalyzedAt) return true; const age = Date.now() - new Date(lastAnalyzedAt).getTime(); return age >= ANALYSIS_COOLDOWN_MS; // otherwise once per day at most}
3. Moving the layout itself earns you one-star reviews
This one is easy to overlook. Changing the order of content is welcome, but changing the skeleton — button positions, tab structure — makes users feel the app "got worse" or that "a button disappeared." During a period when I dynamically switched the home structure of an intention app, in-app inquiries rose to twelve a month.
My call: keep the layout skeleton fixed and confine personalization to the ordering and contents of cards and sections. After fixing the skeleton, inquiries fell to two a month. What you may change dynamically is the arrangement, not the position.
4. "Median of recent open times" beat "just before active"
The findOptimalSendTime in the body aims for the moment just before a user becomes active. But when I A/B tested it, sending at the median of the hours a user actually opened the app over the last two weeks produced consistently higher open rates in my apps. The theoretical "just before active" window misfires for users with irregular routines. After switching in a calming-tone app, notification open rate rose from 4.8% to 9.1%.
// median-open-hour.ts — target the median hour the user actually openedfunction medianOpenHour(openEvents: { created_at: string }[]): number { if (openEvents.length === 0) return 9; // no data: 9am const hours = openEvents .map(e => new Date(e.created_at).getHours()) .sort((a, b) => a - b); const mid = Math.floor(hours.length / 2); // for even counts, lean to the earlier hour (arriving a bit early is safer) return hours.length % 2 === 0 ? hours[mid - 1] : hours[mid];}
5. Instant cache display feels great — until the order shuffles underneath
Showing a local cache first and fetching the latest in the background, like usePersonalization does, helps perceived speed. But applying the freshly fetched order immediately means cards swap while the user is mid-scroll, which reads as a visual jump. In a wallpaper grid I literally got "it makes me dizzy" complaints over this.
I now defer the newly fetched order rather than applying it on the spot — holding it until the next launch or until the user leaves the screen. It's stale-while-revalidate, but I've found that not applying the revalidate result instantly is what matters for the experience.
6. You can still personalize on-device for users who decline ATT
When you can't get App Tracking Transparency consent on iOS, it's tempting to give up on advanced server-side analysis. In my apps the decline rate hovered around 62%. But even without sending behavioral data to a server — aggregating only on-device and scoring locally — content reordering and notification timing still work perfectly well.
Not degrading the experience based on tracking consent is, to me, partly a matter of honesty toward people who'll use the app for a long time. Send only aggregated data for consenting users, keep everything on-device for those who decline — that two-tier setup lets privacy and experience coexist.
One next step
If you're about to add personalization, my advice is not to build every layer at once. What I learned the long way is that the quiet operational habit — change only the ordering, only after enough data has accumulated — beats flashy AI inference.
As a step you can take today, instrument event tracking only, then watch the data for a week. Once you can see when people actually open the app and which content holds them, what to optimize next becomes obvious on its own. AI analysis and dynamic layouts can wait until you have that feel.
If this saves a fellow solo developer from the cold-start and API-cost detours I took, I'll be glad. Thank you for reading.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.