
Join the Conversation!
Subscribing gives you access to the comments so you can share your ideas, ask questions, and connect with others.
"Please login to view comments"
Subscribing gives you access to the comments so you can share your ideas, ask questions, and connect with others.
You’ve made it to the final step of building this feature.
Recommendation.
Now, pause for a second—what pops into your head when you hear "recommendation"? After everything you’ve built so far, does it feel like there’s going to be some complex magic involved?
If you’re thinking it’s some kind of mysterious data science trick—nah, not really. You’re overthinking it.
There’s a saying: “You suffer more in imagination than in reality .” Pretty true here too.
Most logic looks scary at first. It’s totally normal to feel like, “Nope, this is out of my league.” But trust me, once you break it down, it’s rarely as hard as it seems. Just take a deep breath—and tackle it step by step.
Let’s walk through it.
You already have data on what the user has interacted with—things like posts they’ve created, upvoted, bookmarked, or viewed.
So what can you do with that?
Easy—pull all the questions they interacted with.
Then, figure out what tags those questions have in common. And that’s your base to start building meaningful recommendations.
Fair question.
We focus on tags in our recommendation logic because they’re a super straightforward way to understand what a user is interested in. Tags reflect the main topics of each question—so if someone keeps interacting with questions about “Next.js” or “React,” it makes sense to recommend more content around those tags.
Thanks to the way we’ve structured our models, we don’t need to build a complicated system to get great results. Simple logic, smart results — giving users more of what they love without the extra noise.
So, what should you do once you have the tags from those questions?
Easy — we group them to keep only the unique ones. Then, we use those tags to find other questions that match the same topics. But we make sure not to include questions the user has already interacted with or ones they’ve created themselves.
Does this make sense? Let me recap it again:
That’s it. When you break it down like this, it’s not so hard, right? Give it a shot and see how it goes.
Alright, here’s my solution so you can compare and refer.
Start by creating a new function called getRecommendedQuestions in the lib/actions/question.action.ts file — place it just above the getQuestions function.
You’ll only call this function when the filter is set to "recommended" inside getQuestions. That means it won’t be used anywhere else, so you can treat it like a helper specific to that case.
To make this work, you need to pass a few parameters to the helper—like which user you’re getting recommendations for, plus search query and pagination details.
So, define a proper type for the function parameters in types/action.d.ts:
interface RecommendationParams {
userId: string;
query?: string;
skip: number;
limit: number;
}
Now that everything's in place, go ahead and implement the logic for fetching recommended questions inside lib/actions/question.action.ts.
export async function getRecommendedQuestions({
userId,
query,
skip,
limit,
}: RecommendationParams) {
// Get user's recent interactions
const interactions = await Interaction.find({
user: new Types.ObjectId(userId),
actionType: "question",
action: { $in: ["view", "upvote", "bookmark", "post"] },
})
.sort({ createdAt: -1 })
.limit(50)
.lean();
const interactedQuestionIds = interactions.map((i) => i.actionId);
// Get tags from interacted questions
const interactedQuestions = await Question.find({
_id: { $in: interactedQuestionIds },
}).select("tags");
// Get unique tags
const allTags = interactedQuestions.flatMap((q) =>
q.tags.map((tag: Types.ObjectId) => tag.toString())
);
// Remove duplicates
const uniqueTagIds = [...new Set(allTags)];
const recommendedQuery: FilterQuery<typeof Question> = {
// exclude interacted questions
_id: { $nin: interactedQuestionIds },
// exclude the user's own questions
author: { $ne: new Types.ObjectId(userId) },
// include questions with any of the unique tags
tags: { $in: uniqueTagIds.map((id) => new Types.ObjectId(id)) },
};
if (query) {
recommendedQuery.$or = [
{ title: { $regex: query, $options: "i" } },
{ content: { $regex: query, $options: "i" } },
];
}
const total = await Question.countDocuments(recommendedQuery);
const questions = await Question.find(recommendedQuery)
.populate("tags", "name")
.populate("author", "name image")
.sort({ upvotes: -1, views: -1 }) // prioritizing engagement
.skip(skip)
.limit(limit)
.lean();
return {
questions: JSON.parse(JSON.stringify(questions)),
isNext: total > skip + questions.length,
};
}
A lot is going on here, but let me break it down for you step by step.
First, you retrieve all interactions from the Interactions model.
const interactions = await Interaction.find({
user: new Types.ObjectId(userId),
actionType: "question",
action: { $in: ["view", "upvote", "bookmark", "post"] },
})
.sort({ createdAt: -1 })
.limit(50)
.lean();
Here, you're using new Types.ObjectId(userId) to convert the userId string into a MongoDB ObjectId, ensuring it matches the database field type. Then, you're filtering the interactions by setting actionType: "question" along with specific action type filters to retrieve only the relevant question interactions.
Next, you extract the question IDs:
const interactedQuestionIds = interactions.map((i) => i.actionId);
Then, you fetch the tags of these questions:
const interactedQuestions = await Question.find({
_id: { $in: interactedQuestionIds },
}).select("tags");
Afterward, you extract and flatten the tags to ensure you get a proper tags array:
const allTags = interactedQuestions.flatMap((q) =>
q.tags.map((tag: Types.ObjectId) => tag.toString())
);
You’re using flatMap here, which allows you to transform each item in an array and then flatten the result into a single array.
For instance, if you have an array like:
const arr = [[1, 2], [3, 4]];
A flatMap operation on it would look like this:
arr.flatMap(x => x);
And it would result in:
Output: [1, 2, 3, 4]
You can learn more about flatMap here.
Continuing with the recommendation logic, you use Set to ensure the tags are unique:
const uniqueTagIds = [...new Set(allTags)];
Here, the new Set(allTags) removes duplicate tag IDs and [...] converts the Set back into an array.
Then, you build the main query based on the algorithm we discussed earlier, ensuring you exclude questions the user has interacted with or created:
const recommendedQuery: FilterQuery<typeof Question> = {
_id: { $nin: interactedQuestionIds },
author: { $ne: new Types.ObjectId(userId) },
tags: { $in: uniqueTagIds.map((id) => new Types.ObjectId(id)) },
};
You also add a query condition for text search in the question's title or content, as you've done before:
if (query) {
recommendedQuery.$or = [
{ title: { $regex: query, $options: "i" } },
{ content: { $regex: query, $options: "i" } },
];
}
Finally, you count the total documents and retrieve the recommended questions:
const questions = await Question.find(recommendedQuery)
.populate("tags", "name")
.populate("author", "name image")
.sort({ upvotes: -1, views: -1 })
.skip(skip)
.limit(limit)
.lean();
Have you got it? If not, feel free to ask questions in the comments or on Discord. We’re here to help.
The final step is to call the getRecommendedQuestions function whenever the filter is set to "recommended."
So, in the same file lib/actions/question.action.ts, go to the getQuestions function and call getRecommendedQuestions like this
export async function getQuestions(params: PaginatedSearchParams): Promise<
ActionResponse<{
questions: Question[];
isNext: boolean;
}>
> {
const validationResult = await action({
params,
schema: PaginatedSearchParamsSchema,
});
if (validationResult instanceof Error) {
return handleError(validationResult) as ErrorResponse;
}
const { page = 1, pageSize = 10, query, filter } = params;
const skip = (Number(page) - 1) * pageSize;
const limit = pageSize;
const filterQuery: FilterQuery<typeof Question> = {};
let sortCriteria = {};
try {
// Recommendations
**if (filter === "recommended") {
const session = await auth();
const userId = session?.user?.id;
if (!userId) {
return { success: true, data: { questions: [], isNext: false } };
}
const recommended = await getRecommendedQuestions({
userId,
query,
skip,
limit,
});
return { success: true, data: recommended };
}**
// Search
if (query) {
filterQuery.$or = [
{ title: { $regex: query, $options: "i" } },
{ content: { $regex: query, $options: "i" } },
];
}
// Filters
switch (filter) {
case "newest":
sortCriteria = { createdAt: -1 };
break;
case "unanswered":
filterQuery.answers = 0;
sortCriteria = { createdAt: -1 };
break;
case "popular":
sortCriteria = { upvotes: -1 };
break;
default:
sortCriteria = { createdAt: -1 };
break;
}
const totalQuestions = await Question.countDocuments(filterQuery);
const questions = await Question.find(filterQuery)
.populate("tags", "name")
.populate("author", "name image")
.lean()
.sort(sortCriteria)
.skip(skip)
.limit(limit);
const isNext = totalQuestions > skip + questions.length;
return {
success: true,
data: {
questions: JSON.parse(JSON.stringify(questions)),
isNext,
},
};
} catch (error) {
return handleError(error) as ErrorResponse;
}
}
You’re still here?
Go ahead and test whether your recommendation algorithm is working. That's all! 😉
Complete source code for this lesson is available at
How did you manage to remove the blur property and reach here?
Upgrading gives you access to quizzes so you can test your knowledge, track progress, and improve your skills.
00:00:00 Now that everything's in place, let's go ahead and implement the logic for fetching the recommended questions inside of the question.action.ts.
00:00:10 I'll actually do it just above the getQuestions asynchronous function by saying export async function, getRecommendedQuestions,
00:00:22 and I'll accept a couple of different params into it.
00:00:26 We can get the user ID.
00:00:30 the query, the amount of items that we want to skip, and the final limit of the items we want to display.
00:00:36 In this questions, the items being the questions.
00:00:39 And this is of a type recommendation params.
00:00:45 So here you can see exactly what we need to pass into it.
00:00:48 Now within this function, we first want to get access to the user's recent interactions.
00:00:54 So I'll say const interactions is equal to await interaction dot find, and here we want to do some filtering to figure out the interactions of that specific users.
00:01:08 So I can say user is new types dot object ID of that specific user ID.
00:01:19 And we're using this function to convert the user ID string into a MongoDB object ID, ensuring that it matches the database field type.
00:01:28 Then we want to further filter out the interactions by setting the action type to a question, along with specific actions type filters to retrieve only
00:01:40 the relevant question interactions, such as actions will include, so that's going to be a dollar sign in, either a view or maybe an upvote.
00:01:54 or a bookmark, or a post.
00:01:57 So these are the different types of actions that we want to include.
00:02:01 Then we want to sort it by created at minus one, so the newest ones at the top, and we want to limit it to maybe like 50. and want to make it lean like this.
00:02:14 What lean does is it turns the objects that we get returned from the database from not so typical MongoDB objects to regular JavaScript objects.
00:02:24 So it's easier to work with them once we actually forward them over.
00:02:28 Next, we want to extract the question IDs.
00:02:32 So we'll say const interacted question IDs is equal to interactions.map where we get each individual interaction, which we can call i.
00:02:44 And we just want to return i.actionID.
00:02:49 Then we need to fetch the tags of these questions.
00:02:52 So we'll say cost interacted questions is equal to wait question.find We want to find each question by an underscore ID,
00:03:03 which includes the interactive question IDs.
00:03:07 And then from each one, we want to select a property of tags.
00:03:12 Once we get all of those tags, we want to extract and flatten those tags to ensure that we get them into a symbol to understand the rate.
00:03:19 So say const all tags.
00:03:22 is equal to interactedQuestions.flatMap, where we get each individual question.
00:03:30 And for each one, we want to take the array of tags and map over them, where we get each individual tag of a type, typeStatObjectId,
00:03:40 and we want to return that tag stringified.
00:03:43 Like this.
00:03:44 Complex logic for a complex application.
00:03:47 I know, it's not always as easy as just taking an array and mapping over it.
00:03:52 Sometimes you want to have some additional logic that allow you to properly get access to those strings or tags.
00:03:59 Here, we're using a flat map.
00:04:01 which allows you to transform each item in an array and then flatten the result into a single array.
00:04:08 For example, if you have a simple array that looks something like this, an array of arrays, 1, 2 in the first one, 3, 4 in the second one,
00:04:17 and then if you run an array flat map on it, the result would be a single flat array.
00:04:22 1, 2, 3, 4. Not something that's used often, but it comes in super handy in these types of situations.
00:04:30 Here, we needed it because maybe we had a JS and then React in one question as tags, and then in the other question, maybe we had JS and Node.
00:04:40 So what we need to do is get all tags.
00:04:42 So from the first one, grab JS and React, and then from the second one, simply grab Node, giving us access to all of the tags.
00:04:51 Continuing with the recommendation logic, you can use a set to ensure that tags are unique.
00:04:57 Why?
00:04:58 Because right now we might have some duplicates like JS here and JS there.
00:05:03 So this is another cool JavaScript feature.
00:05:05 We can say const unique tag IDs is equal to an array where we spread a new set of all tags.
00:05:16 which will simply remove any duplicates and give us the IDs.
00:05:20 But instead of simply doing a new set like this, which would do what we need, I also want to turn it back into an array,
00:05:26 which is why we wrap it with array signs and then spread the output of a set.
00:05:30 Then we are ready to build the main query based on the algorithm that we discussed earlier.
00:05:36 ensuring that we exclude the questions the user has interacted with before or already created.
00:05:43 So say const recommended query of a type filter query.
00:05:50 that takes in a type of question is equal to an object where we have an underscore ID, where the interactive question IDs are not included because they
00:06:02 have already been accounted for.
00:06:04 And then we have the author.
00:06:05 In this recommended query, we don't want to recommend things that this author has posted.
00:06:10 So for the author, also say ne and then I'll say new.
00:06:15 types.objectid to which we're going to pass the user ID.
00:06:19 So don't include those users or those authors.
00:06:23 And we want to do a similar thing with the tags where I'll say include the tags where we get these unique tag IDs, map over them,
00:06:32 and create a new object ID out of each of these tags.
00:06:36 So, while we don't want to include the interactive questions or the same author that we're currently on, we do want to include the unique tag IDs that
00:06:46 this user has interacted with to form the baseline of our recommendation algorithm.
00:06:52 You can also add a simple query condition for text search by title or content.
00:06:58 We've done that before.
00:06:59 Like, if a query exists, then form the recommended query or run the OR operator on it, either based on the title, so it's a regular expression of the query,
00:07:11 or based on the content.
00:07:13 Both case-insensitive.
00:07:16 Finally, you can count the total documents and retrieve the recommended questions.
00:07:22 We can do that by saying const questions is equal to await question.find and to it, we simply pass the recommended query.
00:07:33 We populate on those questions all the names of the tags, and we also want to populate the name and the image of the authors of those questions.
00:07:42 We then want to sort them based on upvotes.
00:07:47 So the ones that have most upvotes first.
00:07:49 And then based on views, the ones that have most views first as well, we want to implement the skip and the limit in case we're working with pagination.
00:07:58 And finally, we make it lean so it's easier to work on it on the front-end side because it's a JavaScript object.
00:08:03 Does it make sense?
00:08:04 If not, feel free to ask questions just down below in the comments and I'd be happy to respond.
00:08:10 I know this is not super simple to understand, but with some time and practice, it'll make sense.
00:08:17 And now finally, we can return an object where questions are going to be equal to JSON parse, JSON stringify questions.
00:08:26 And we also want to deal with the pagination by saying, does the next page exist?
00:08:31 Well, if the total is greater than the skip plus the question's length, then it must mean that it exists.
00:08:40 But where's this total coming from?
00:08:43 Well, it's coming from the count of the questions.
00:08:46 So say const total is equal to await question.countDocuments filtered by the recommended query.
00:08:55 Great.
00:08:57 Finally, we'll be able to use this getRecommendedQuestions function right below in the getQuestions when we are returning them to the user.
00:00:00 So what do you say that we give it a shot?
00:00:02 If you go back to the homepage right now, you'll see that we have just two dummy filters, React and JavaScript.
00:00:10 But here, we want to implement our recommendation system.
00:00:14 So if you head back over to the homepage where we're calling the home filter, you'll notice that right now, only two are here.
00:00:21 But we actually want to implement these filters which are commented out.
00:00:26 So uncomment them, newest, popular, unanswered, and then here's a part recommended.
00:00:31 So now if you go back, you can of course sort or filter by newest, popular, or unanswered.
00:00:39 And then there's the recommended, which returns the questions that are recommended for you.
00:00:44 Right now, there are no questions that are recommended for us, which is totally okay, because let's be honest, we are the only person that has been asking
00:00:52 questions in the first place.
00:00:53 So what do you say that we log out and then ask some questions from the other account?
00:00:58 I've now signed in with the other account.
00:01:01 Don't let the logo confuse you, I use it for both.
00:01:04 And I'll ask a question that might be relevant for the first user.
00:01:08 That question will most likely include the React tag because I can see they interacted with it.
00:01:13 So I'll say something like, what is React?
00:01:17 And say, I'm struggling.
00:01:21 useEffect, useState, what are those?
00:01:26 We don't know.
00:01:27 And then I'll add a tag of React to it and ask a question.
00:01:32 Of course, our questions have to be a bit more detailed, so let's make them a bit more detailed.
00:01:38 Perfect.
00:01:39 So now we have tested the create question functionality, which still works amazing.
00:01:45 And we have it here by JavaScript mastery, not by Adrian JavaScript mastery.
00:01:49 And while we're here, let's also do another, which is completely out of the blue, which the other Adrian is not interested in whatsoever.
00:01:59 So let's do something like question title.
00:02:02 What is Java?
00:02:04 And then here we can say.
00:02:06 Hey, I want to know what Java is, not JavaScript.
00:02:12 Java.
00:02:13 Okay.
00:02:14 The other agent is definitely not interested in that.
00:02:16 And I'll add a tag of Java and ask a question.
00:02:21 Now the real question is if we log out from this account and log into our old account, we do get this one hydration error that I think we also seen before,
00:02:34 but hasn't popped up for quite some time now.
00:02:36 So to fix this, I think I had just a little issue right here.
00:02:40 Instead of action, I said actions.
00:02:43 So it wasn't able to find it properly in the database.
00:02:45 So make sure that right here you say action.
00:02:48 So is that specific action included?
00:02:51 And also below, where you're sorting, you can sort by upvotes, not upvoted.
00:02:56 If you do that and reload, you should be able to see that there's one recommended post, what is React?
00:03:02 And what is Java is not appearing because you haven't interacted with Java posts before.
00:03:07 If this one isn't appearing for you either, that's okay, maybe the interaction wasn't counted before.
00:03:12 So as you continue interacting with the platform, check out some posts or ask new questions regarding React, it should show up.
00:03:18 But now, if we go ahead and create a new post about Java, so we can maybe create it, I'll say, Java is great?
00:03:27 I'll add a couple of questions right here and add a Java tag.
00:03:34 and ask a question.
00:03:36 Let's try to be a bit more descriptive on how great Java is.
00:03:39 If I do that and now head back over into recommended, you will see that Java is appearing as well because now our algorithm assumes that the current user
00:03:49 is interested in Java.
00:03:51 Just how cool is that?