A personalized feed in FlutterFlow uses a Cloud Function to rank content by matching user interests, recency, engagement score, and topic diversity. Run the ranking algorithm server-side when a user's interests change or when new content is published — never on every page load. Store pre-computed feed arrays in a user_feeds Firestore collection so the app just reads a sorted list.
Algorithm-Driven Feeds with Pre-Computed Rankings
Personalized content feeds feel magical to users but are deceptively simple under the hood: collect what topics interest the user, score each piece of content against those interests plus a recency and engagement bonus, sort the results, and cache the ranked list. The key architectural decision is when to run this ranking. Running it on every page load for every user is expensive and slow. Instead, trigger the ranking Cloud Function when something meaningful changes — new content published, user interests updated, or once daily as a background refresh. The app then reads a pre-computed sorted array from Firestore, which is fast and cheap.
Prerequisites
- A Firebase project with Firestore, Authentication, and Cloud Functions enabled (Blaze plan)
- A FlutterFlow project connected to that Firebase project
- A Firestore 'content' collection with documents that have a 'topics' array field and engagement metrics
- Basic familiarity with FlutterFlow's Backend Queries and Action Flows
Step-by-step guide
Build an interest selection screen in FlutterFlow onboarding
Build an interest selection screen in FlutterFlow onboarding
Create a page called 'InterestSelectionPage'. Add a GridView with a Backend Query on a Firestore 'topics' collection (documents with fields: id, name, emoji, color). Each grid item is a Component called 'TopicChip' with a toggle state: tapped chips show a filled background in the topic's color with a checkmark; untapped chips show an outlined card. Store the selected topic IDs in a Page State List variable called 'selectedTopics'. At the bottom, add a 'Continue' button that is disabled when selectedTopics is empty. Its action flow writes the selectedTopics array to the current user's Firestore document at users/{uid}/interests, then triggers your Cloud Function to build the initial feed, then navigates to the main feed page.
Expected result: Users tap topics during onboarding and see chips toggle on/off. Tapping Continue saves their interests and navigates to a feed with relevant content.
Design the Firestore data model for content and feeds
Design the Firestore data model for content and feeds
Your 'content' collection needs these fields per document: title (String), body (String), image_url (String), topics (Array of Strings — topic IDs), author_id (String), created_at (Timestamp), view_count (Integer), like_count (Integer), share_count (Integer), and engagement_score (Float — updated by a Cloud Function). Create a second collection called 'user_feeds'. Each document in user_feeds uses the user's UID as the document ID. The document has a single field called 'feed' — an Array of Strings containing content document IDs in ranked order, and a last_computed Timestamp. The FlutterFlow app queries user_feeds/{uid} to get the ordered ID array, then fetches content documents by those IDs.
Expected result: Both collections exist in Firestore with the correct field types. The user_feeds collection starts empty and will be populated by the Cloud Function.
Write the ranking Cloud Function
Write the ranking Cloud Function
Create a Firebase Cloud Function called 'computeUserFeed'. It can be triggered in two ways: as an HTTP callable (called directly from FlutterFlow when interests change) and as a Firestore trigger on content document creation. The function fetches the user's interests array, fetches all published content (use a query limit — start with 200 most recent), scores each content item, sorts by score, and writes the top 50 IDs to user_feeds/{uid}. The scoring formula weighs: interest match (are any of the content's topics in the user's interests? +40 points per match), recency (+30 points if published today, +20 if this week, +10 if this month), engagement score (+20 points scaled 0-20 based on total interactions), and diversity penalty (-15 points if the last 3 feed items have the same topic as this one).
1// functions/computeUserFeed.js2const functions = require('firebase-functions');3const admin = require('firebase-admin');45// Score a content item for a user's interests6function scoreContent(content, userInterests) {7 let score = 0;8 const topics = content.topics || [];9 // Interest match: +40 per matching topic10 const matches = topics.filter(t => userInterests.includes(t)).length;11 score += matches * 40;12 // Recency bonus13 const ageMs = Date.now() - content.created_at.toMillis();14 const ageDays = ageMs / (1000 * 60 * 60 * 24);15 if (ageDays < 1) score += 30;16 else if (ageDays < 7) score += 20;17 else if (ageDays < 30) score += 10;18 // Engagement bonus (0-20 points)19 const engagementScore = content.engagement_score || 0;20 score += Math.min(engagementScore, 20);21 return score;22}2324exports.computeUserFeed = functions.https.onCall(async (data, context) => {25 if (!context.auth) throw new functions.https.HttpsError('unauthenticated', 'Must be signed in');26 const uid = context.auth.uid;27 const db = admin.firestore();28 // Get user interests29 const userDoc = await db.collection('users').doc(uid).get();30 const interests = userDoc.data()?.interests || [];31 if (interests.length === 0) return { success: false, reason: 'No interests set' };32 // Fetch recent content33 const contentSnap = await db.collection('content')34 .orderBy('created_at', 'desc')35 .limit(200)36 .get();37 // Score and sort38 const scored = contentSnap.docs.map(doc => ({39 id: doc.id,40 score: scoreContent(doc.data(), interests),41 topics: doc.data().topics || []42 }));43 scored.sort((a, b) => b.score - a.score);44 // Apply diversity: avoid 3+ consecutive same-topic items45 const feed = [];46 const recentTopics = [];47 for (const item of scored) {48 if (feed.length >= 50) break;49 const topicOverlap = item.topics.filter(t => recentTopics.slice(-3).includes(t)).length;50 if (topicOverlap > 0 && feed.length > 3) continue;51 feed.push(item.id);52 recentTopics.push(...item.topics);53 }54 await db.collection('user_feeds').doc(uid).set({55 feed,56 last_computed: admin.firestore.FieldValue.serverTimestamp()57 });58 return { success: true, count: feed.length };59});Expected result: Calling the function from Postman or the Firebase console creates a user_feeds document with a sorted array of 50 content IDs. Check that high-interest-match content appears near the top.
Build the feed ListView in FlutterFlow
Build the feed ListView in FlutterFlow
On your main feed page, add a Backend Query that fetches the single document from user_feeds/{current_user_uid}. Store the result in a page variable. Then add a ListView whose children are built from the feed array. Since Firestore does not support ordered-by-array-position queries, use a Custom Action called 'fetchFeedContent' that reads the feed ID array and performs multiple document fetches (using getAll in batches of 10) returning a List of content maps in the correct order. Bind this list to the ListView. Each list item is a 'ContentCard' Component showing the title, image, topic tags, author, time ago, and like/view counts.
Expected result: The feed loads in under 1 second (it is reading a pre-sorted array, not computing) and shows content ordered by the user's interest match.
Track implicit feedback to improve feed quality
Track implicit feedback to improve feed quality
Implicit feedback — what users actually engage with — is more reliable than explicit interest selection. Track two events: a view event (logged when a content card is visible for more than 2 seconds using a Custom Action with a Timer) and an interaction event (tap, like, share). In your ContentCard component's onVisible action, start a 2-second timer; if the card is still visible after 2 seconds, call a Cloud Function 'trackFeedEvent' with event_type: 'view', content_id, and user_id. In the like/share button actions, call the same function with event_type: 'interaction'. The Cloud Function updates the content's engagement_score and increments view_count or like_count in Firestore.
Expected result: After browsing the feed for a few minutes, content's view_count fields increment in Firestore. Re-running computeUserFeed should now score recently-engaged content higher.
Complete working example
1// Firebase Cloud Functions for personalized feed2// computeUserFeed: ranks and stores feed for a user3// trackFeedEvent: records views and interactions4// updateEngagementScores: daily scheduled function to update scores56const functions = require('firebase-functions');7const admin = require('firebase-admin');89if (!admin.apps.length) admin.initializeApp();10const db = admin.firestore();1112function scoreContent(content, userInterests) {13 let score = 0;14 const topics = content.topics || [];15 const matches = topics.filter(t => userInterests.includes(t)).length;16 score += matches * 40;17 const ageMs = Date.now() - (content.created_at?.toMillis?.() || 0);18 const ageDays = ageMs / 86400000;19 if (ageDays < 1) score += 30;20 else if (ageDays < 7) score += 20;21 else if (ageDays < 30) score += 10;22 const eng = Math.min(content.engagement_score || 0, 20);23 score += eng;24 return score;25}2627exports.computeUserFeed = functions.https.onCall(async (data, context) => {28 if (!context.auth) throw new functions.https.HttpsError('unauthenticated', 'Sign in required');29 const uid = data?.uid || context.auth.uid;30 const userDoc = await db.collection('users').doc(uid).get();31 const interests = userDoc.data()?.interests || [];32 if (!interests.length) return { success: false, reason: 'No interests' };3334 const snap = await db.collection('content')35 .where('published', '==', true)36 .orderBy('created_at', 'desc')37 .limit(200)38 .get();3940 const scored = snap.docs.map(d => ({41 id: d.id, data: d.data(),42 score: scoreContent(d.data(), interests)43 })).sort((a, b) => b.score - a.score);4445 // Diversity filter46 const feed = [];47 const recentTopics = [];48 for (const item of scored) {49 if (feed.length >= 50) break;50 const sameTopicRecently = (item.data.topics || [])51 .some(t => recentTopics.slice(-6).includes(t));52 if (sameTopicRecently && feed.length > 5) continue;53 feed.push(item.id);54 recentTopics.push(...(item.data.topics || []));55 }5657 await db.collection('user_feeds').doc(uid).set({58 feed, last_computed: admin.firestore.FieldValue.serverTimestamp()59 });60 return { success: true, count: feed.length };61});6263exports.trackFeedEvent = functions.https.onCall(async (data, context) => {64 if (!context.auth) throw new functions.https.HttpsError('unauthenticated', 'Sign in required');65 const { content_id, event_type } = data;66 if (!content_id || !event_type) return { success: false };67 const ref = db.collection('content').doc(content_id);68 const updates = { view_count: admin.firestore.FieldValue.increment(1) };69 if (event_type === 'interaction') {70 updates.like_count = admin.firestore.FieldValue.increment(1);71 }72 await ref.update(updates);73 return { success: true };74});7576exports.updateEngagementScores = functions.pubsub77 .schedule('every 24 hours').onRun(async () => {78 const snap = await db.collection('content').get();79 const batch = db.batch();80 snap.docs.forEach(doc => {81 const d = doc.data();82 const views = d.view_count || 0;83 const likes = d.like_count || 0;84 const shares = d.share_count || 0;85 const score = Math.min((views * 0.5 + likes * 2 + shares * 3) / 100, 20);86 batch.update(doc.ref, { engagement_score: score });87 });88 await batch.commit();89 console.log(`Updated engagement scores for ${snap.size} content items`);90 return null;91 });Common mistakes when creating a Personalized Content Feed in FlutterFlow
Why it's a problem: Running the ranking algorithm on every page load for every user
How to avoid: Pre-compute feeds when interests change or content is published. Read the pre-computed array on page load — a single Firestore document read takes under 100ms.
Why it's a problem: Showing only exact interest matches — no content from adjacent topics
How to avoid: Include a 'discovery' portion (10-15% of the feed) of high-engagement content regardless of topic match. This introduces users to new interests and ensures the feed always has enough content.
Why it's a problem: Storing the full content objects in the user_feeds array instead of just IDs
How to avoid: Store only content document IDs in the feed array. Fetch the actual content documents separately — they will always be fresh and you avoid duplication.
Why it's a problem: Never refreshing the feed once it is computed
How to avoid: Trigger computeUserFeed via a Firestore onCreate trigger on the content collection (so new content updates feeds immediately) and also run it as a daily scheduled function as a safety net.
Best practices
- Pre-compute feeds server-side on triggers rather than on client request — this keeps the app fast and your Cloud Function costs low.
- Include a 'diversity penalty' in your scoring formula to avoid showing 10 articles from the same topic in a row, even if they score highly.
- Give users explicit control over their feed — a 'Manage Interests' settings screen that re-triggers feed computation immediately on save.
- Log feed computation timing in Firestore so you can monitor how long ranking takes as content volume grows.
- Use Firestore Pagination (startAfter) for the feed ListView rather than loading all 50 items at once — load 10 at a time as the user scrolls.
- A/B test different scoring weights (interest match vs recency vs engagement) by creating a user_variant field and using different weights per variant group.
- Set a TTL on user_feeds documents using Firebase's TTL policy so feeds for inactive users are automatically deleted and not computed unnecessarily.
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I'm building a personalized content feed in Firebase Cloud Functions. Write a Node.js function that takes a user's Firestore document (which has an 'interests' array of topic IDs), fetches the 200 most recent documents from a 'content' collection (each has a 'topics' array, 'created_at' timestamp, 'view_count', and 'like_count'), scores each item by interest match (40 points per matching topic), recency (30 for today, 20 for this week), and engagement (0-20 scaled), applies a diversity filter to avoid topic repetition, and writes the top 50 content IDs to a user_feeds/{uid} document.
In my FlutterFlow project I have a 'user_feeds' Firestore collection where each document (keyed by user UID) has a 'feed' field that is an Array of content document IDs in ranked order. I want to display these as a scrollable list of content cards on my FeedPage. How do I set up a Backend Query to read user_feeds/{authenticated_user_uid}, then use those IDs to fetch and display the corresponding content documents in the correct order?
Frequently asked questions
How often should I recompute a user's feed?
Recompute on three triggers: when the user updates their interests, when new content is published (Firestore onCreate trigger), and once daily as a scheduled background refresh. Avoid recomputing on every app open — the pre-computed result is sufficient for most use cases.
What if a user has no interests set yet?
Show a trending feed instead — the top 50 content items sorted by engagement_score. This gives new users a good first experience while they browse, and you can prompt them to personalize after their first session.
Can this approach handle 100,000 users?
Yes. Each user_feeds document is independent and small (50 IDs, under 5KB). The computeUserFeed function runs per user, not per all users simultaneously. The daily scheduled refresh can use Fan-out by processing users in batches of 100 with Firestore pagination.
How do I prevent the feed from showing content the user already read?
Track read content IDs in a Firestore subcollection: users/{uid}/read_content. In the computeUserFeed function, fetch the read IDs and exclude them from the ranked list. After the user reads an item, add its ID to the read_content subcollection via a background action.
Does this work with Supabase instead of Firestore?
Yes. Replace the Firestore reads with Supabase table queries in your Cloud Function (or use a Supabase Edge Function instead). The scoring logic is identical — only the database read/write calls change.
How do I show the user why a piece of content was recommended?
Add a 'reason' field to each entry in the feed array — e.g., { id: 'abc', reason: 'Because you like Photography' }. Store this object array in user_feeds instead of a plain ID array. Display the reason text below each content card.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation