
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.
One of the easiest ways to keep users coming back to your website is by adding gamification. Why? Because people love numbers—whether it's grades, money, or progress bars. We're wired to care about them, and smart product builders use that to their advantage.
Think about it. Duolingo rewards you for daily practice. E-commerce sites do it to encourage more shopping. Even StackOverflow gives you badges based on how much you help others. Numbers make things feel fun and rewarding.
And today, you're going to learn how to bring that magic to your app.
But before we jump into code, let’s plan how the system should actually work.
First, you’ll need to decide what kind of badges users can earn—and based on what.
That’s where metrics come in.
Which ones matter? Your app already collects a lot of useful data. You could add more, but to start, here are some good ones:
All of these are easy to calculate with simple queries. Based on them, you can award badges like GOLD, SILVER, and BRONZE in each category.
Here’s how I’ve structured the badge system for gamification:
Of course, these are just sample numbers. You can tweak them however you like—or even add new metrics as your app grows. A nice future upgrade could be building a small admin panel where you control badge types, metrics, and thresholds on the fly.
For now, while we’re all aligned, let’s define the types of badges. Head over to types/global.d.ts and define the badge levels like this:
interface Badges {
GOLD: number;
SILVER: number;
BRONZE: number;
}
Then, set up your badge criteria in the constants/index.ts file:
export const BADGE_CRITERIA = {
QUESTION_COUNT: {
BRONZE: 10,
SILVER: 50,
GOLD: 100,
},
ANSWER_COUNT: {
BRONZE: 10,
SILVER: 50,
GOLD: 100,
},
QUESTION_UPVOTES: {
BRONZE: 10,
SILVER: 50,
GOLD: 100,
},
ANSWER_UPVOTES: {
BRONZE: 10,
SILVER: 50,
GOLD: 100,
},
TOTAL_VIEWS: {
BRONZE: 1000,
SILVER: 10000,
GOLD: 100000,
},
};
Perfect. Now comes the fun part—using the real data to calculate badges.
Let’s say a user has the following stats:
Total Questions: 5
Total Answers: 20
Total Upvotes on Questions: 25
Total Upvotes on Answers: 90
Total Views: 880
Now, using this data, your task is to figure out how many badges the user should earn.
If you do the math based on the example above, this mythical user will receive a 🥉 Bronze for total answers given, another 🥉 Bronze for total question upvotes received, and a 🥈 Silver for getting 90 upvotes on answers.
And that’s where it ends—for now. Did you get it?
It’s all about looping through user data and comparing it with the badge rules you’ve defined. Kinda like a LeetCode-style challenge—but with a real-world use case.
Now go ahead and create a new function called assignBadges inside the lib/utils.ts file.
Ready to check out the answer? Here you go
export function assignBadges(params: {
criteria: {
type: keyof typeof BADGE_CRITERIA;
count: number;
}[];
}) {
const badgeCounts: Badges = {
GOLD: 0,
SILVER: 0,
BRONZE: 0,
};
const { criteria } = params;
criteria.forEach((item) => {
const { type, count } = item;
const badgeLevels = BADGE_CRITERIA[type];
Object.keys(badgeLevels).forEach((level) => {
if (count >= badgeLevels[level as keyof typeof badgeLevels]) {
badgeCounts[level as keyof Badges] += 1;
}
});
});
return badgeCounts;
}
If that felt like a lot, don’t worry—let’s walk through it step by step.
First, the function takes a criteria array as input:
export function assignBadges(params: {
criteria: {
type: keyof typeof BADGE_CRITERIA;
count: number;
}[];
}) {
Each item in this criteria has:
Next, you create an object to track how many badges of each type the user will get:
const badgeCounts: Badges = {
GOLD: 0,
SILVER: 0,
BRONZE: 0,
};
Then, the function loops through each item in the criteria array:
criteria.forEach((item) => {
const { type, count } = item;
const badgeLevels = BADGE_CRITERIA[type];
… and look up the corresponding badge level in BADGE_CRITERIA. This tells you the needed value to get that badge, i.e., 10 for bronze, 20 for silver, etc.
And inside the same loop, you’re looping once again for each badge level to…
Object.keys(badgeLevels).forEach((level) => {
if (count >= badgeLevels[level as keyof typeof badgeLevels]) {
badgeCounts[level as keyof Badges] += 1;
}
});
…check if the user’s count meets or exceeds the required number. If yes, it increases the badge count for that level by 1.
Is that understandable? Let me try to make it more real with an example
Let’s say if type is QUESTION_UPVOTES and count is 25 and BADGE_CRITERIA['QUESTION_UPVOTES'] as you’ve defined is
{
BRONZE: 10,
SILVER: 50,
GOLD: 100
}
then the user would get a BRONZE badge because 25 > 10 and 25 < 50. If the count was 70, they’d get both Bronze and Silver.
So, in short, this function takes your activity stats, compares them to the rules, and returns how many of each badge the user deserves. Clean and effective.
Pretty clear now, right?
The next step, as you might’ve guessed, is to create an action that returns all the user stats—like how many questions and answers they've posted, the upvotes they’ve received, and the total views they've got.
While you already have a getUser function in lib/actions/user.action.ts, this time, I’d recommend creating a separate function specifically for user stats. Why? Because of the “Separation of Concerns” principle—let getUser handle just the basic user info and let getUserStats take care of things like question count, answer count, upvotes, and views.
This keeps things cleaner. You get the primary data first, and the secondary stats can load after. Two separate requests instead of jamming everything into one.
And there's a bigger reason too: In the next module, I’ll show you how to make your profile page faster by streaming parts of it using Suspense. For that, splitting the data makes it easier to control what loads first and what can wait.
So, for now, go ahead and update the getUser function in lib/actions/user.action.ts to only return the basic user info. Remove any logic that fetches stats like total questions or answers.
Here’s what your final getUser function should look like:
export async function getUser(params: GetUserParams): Promise<
ActionResponse<{
user: User;
}>
> {
const validationResult = await action({
params,
schema: GetUserSchema,
});
if (validationResult instanceof Error) {
return handleError(validationResult) as ErrorResponse;
}
const { userId } = params;
try {
const user = await User.findById(userId);
if (!user) throw new Error("User not found");
return {
success: true,
data: {
user: JSON.parse(JSON.stringify(user)),
},
};
} catch (error) {
return handleError(error) as ErrorResponse;
}
}
Your app might—actually, scratch that—it will break at this point. That’s because the front end is still expecting the question and answer counts from the getUser function, which you just removed. But don’t panic. That’s totally expected, and you’ll fix it in a moment.
Right now, your next step is to create a new function called getUserStats that takes a user ID as input and does the following:
It’s a bit tough but absolutely doable.
Now, here's how I’d approach it:
export async function getUserStats(params: GetUserParams): Promise<
ActionResponse<{
totalQuestions: number;
totalAnswers: number;
badges: Badges;
}>
> {
const validationResult = await action({
params,
schema: GetUserSchema,
});
if (validationResult instanceof Error) {
return handleError(validationResult) as ErrorResponse;
}
const { userId } = params;
try {
const [questionStats] = await Question.aggregate([
{ $match: { author: new Types.ObjectId(userId) } },
{
$group: {
_id: null,
count: { $sum: 1 },
upvotes: { $sum: "$upvotes" },
views: { $sum: "$views" },
},
},
]);
const [answerStats] = await Answer.aggregate([
{ $match: { author: new Types.ObjectId(userId) } },
{
$group: {
_id: null,
count: { $sum: 1 },
upvotes: { $sum: "$upvotes" },
},
},
]);
const badges = assignBadges({
criteria: [
{ type: "ANSWER_COUNT", count: answerStats.count },
{ type: "QUESTION_COUNT", count: questionStats.count },
{
type: "QUESTION_UPVOTES",
count: questionStats.upvotes + answerStats.upvotes,
},
{ type: "TOTAL_VIEWS", count: questionStats.views },
],
});
return {
success: true,
data: {
totalQuestions: questionStats.count,
totalAnswers: answerStats.count,
badges,
},
};
} catch (error) {
return handleError(error) as ErrorResponse;
}
}
Let me walk you through each of these aggregations step by step.
Starting with the question aggregation,
const [questionStats] = await Question.aggregate([
{ $match: { author: new Types.ObjectId(userId) } },
{
$group: {
_id: null,
count: { $sum: 1 },
upvotes: { $sum: "$upvotes" },
views: { $sum: "$views" },
},
},
]);
This query pulls all the stats related to a user’s questions:
For example, if a user has the following 3 questions
[
{ upvotes: 5, views: 100 },
{ upvotes: 3, views: 50 },
{ upvotes: 2, views: 20 },
];
The aggregation result of the above scenario would be
{
count: 3,
upvotes: 10,
views: 170
}
Same thing and mechanism goes for answer aggregation as well. Nothing different. Two simple aggregation queries to get all desired stats.
Next, you feed this data into the assignBadges function you built earlier, which evaluates it against the criteria to figure out how many badges the user has earned.
const badges = assignBadges({
criteria: [
{ type: "ANSWER_COUNT", count: answerStats.count },
{ type: "QUESTION_COUNT", count: questionStats.count },
{
type: "QUESTION_UPVOTES",
count: questionStats.upvotes + answerStats.upvotes,
},
{ type: "TOTAL_VIEWS", count: questionStats.views },
],
});
And that’s it! You now return all that data.
It might feel intimidating at first, but once you break it down piece by piece, it becomes surprisingly clear, right?
Finally, you hook it up on the profile page. At the top, near user data, call the action
const { user } = data!;
const { data: userStats } = await getUserStats({ userId: id });
Then, pass the data into the Stats component
<Stats
totalQuestions={userStats?.totalQuestions || 0}
totalAnswers={userStats?.totalAnswers || 0}
badges={userStats?.badges || { GOLD: 0, SILVER: 0, BRONZE: 0 }}
reputationPoints={user.reputation || 0}
/>
Now, test your page. Everything should work smoothly.
Go ahead and test it out—everything should run smoothly. If you're seeing all zeros for the badge counts, try lowering the values in BADGE_CRITERIA to something like 1, 2, or 3. That’ll help you confirm if the logic is kicking in correctly.
Once it all clicks, you’ll wonder why it ever felt complicated 😄
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 Okay, we've laid out the foundations to allow us to be able to add badges to specific users.
00:00:08 See, right here, we're trying to get access to the badges by calling some kind of a function, as of now imaginary, as we haven't yet created it,
00:00:18 to which we're passing a prop that has different criteria that have the count of answers, questions, upvotes, and views.
00:00:28 So let's go ahead and implement it.
00:00:30 You can already see that maybe it's going to be coming from utils.
00:00:35 So head over into our utils file, scroll to the bottom, and right here, we want to implement that function.
00:00:41 It'll be called assign badges.
00:00:43 So I'll say export const assign badges, which takes in the params, which is an object that then takes the criteria, which contains a type,
00:00:57 which is a key of type of badge criteria.
00:01:01 So we can have different types of criteria like posting, upvoting, downvoting, and so on.
00:01:06 And we also want to take in the count of that.
00:01:10 And specifically, this will be an array.
00:01:12 Okay.
00:01:12 So if I go right here, I'm going to say that we're accepting an array of different criterias.
00:01:18 Perfect.
00:01:19 Now let me actually space this out in multiple lines and open up a function block.
00:01:24 There we go.
00:01:24 It should look something like this.
00:01:26 So now within here, let's define different badge counts that we want to attribute to a specific user.
00:01:32 I'll say const badge counts of a type badges, and it'll be equal to an object.
00:01:40 We want to set all different badges to zero, such as gold is set to zero, silver is set to zero, and bronze is set to zero as well.
00:01:50 Then we want to destructure the criteria coming from params, and then we want to map over each one of these criteria.
00:01:59 So I'll say criteria.forEach item.
00:02:05 We want to destructure the type and the count, so we know how many actions this user has performed.
00:02:13 And this will be equal to item.
00:02:15 Then we want to figure out a badge level for each one of these actions.
00:02:20 So say const badge levels.
00:02:22 is equal to badge underscore criteria for that specific type.
00:02:27 And this is coming from constants, where we say that if we have a specific number of questions, like 10 questions, then you get a bronze medal.
00:02:37 If we have maybe 50 answers, it's a silver.
00:02:40 And if we have maybe 10,000 views on our posts, it's going to be a silver medal for views.
00:02:47 So once we get the badge levels, we want to map over all of them.
00:02:51 This one is not just a singular array, it's actually more complicated.
00:02:55 So first, I want to run object.keys to extract the keys for each one of these badge levels.
00:03:02 And a key of each one of these badge levels is going to be what?
00:03:06 Well, question count, answer count, and so on.
00:03:10 And then we want to map over each one of these keys with a for each.
00:03:14 So for each level of these metals, we then want to check if.
00:03:19 Count is greater than or equal to badge levels level, and we can also give it a type as key of type of badge levels.
00:03:32 just so TypeScript is safe and knows what we're passing.
00:03:35 And only if the count of what we're trying to achieve right now is higher than the badge level count, then we can say badge counts,
00:03:44 take that specific level of the badge count, like maybe gold or bronze or silver, and then increment it by one by saying plus equal to one.
00:03:54 So what we're doing here is taking a look at the current number of medals or badges.
00:03:59 We're taking a look at the current count and then increasing the proper level of each medal for each category to a right level.
00:04:09 Finally, once we form those new badges, we are ready to just return them.
00:04:14 So I'll say return badge counts.
00:04:17 And now, if you haven't already, you can import this assigned badges from the user.action.ts.
00:04:24 And then you can end up calling it right here below.
00:04:28 get all of those badges and then return them to the front end alongside the typical user data like the total questions and the total answers.
00:04:38 Hope this makes sense.
00:04:39 Just before we test this out in action, I want to point your attention to one thing.
00:04:44 even though it was already explained in the markdown content of this lesson.
00:04:48 But notice how here for the answer stats and the question stats, we're using database aggregations.
00:04:55 Something that we used before and I explained in great detail, but I just wanted to point your attention that here we're using them too,
00:05:02 to get access to the questions and answer stats so we can properly return all of them to the front end.
00:05:09 Now, if you head back over to your application and within the profile, you'll see that all of my badges are still zero.
00:05:17 So is this really working?
00:05:19 I asked a couple of questions, but I don't have any badges.
00:05:22 Not really motivating, is it?
00:05:24 But to test whether it truly works, let me go back to my application and head over to the index of constants.
00:05:32 And I want to change my batch criteria to something very small.
00:05:35 Like instead of maybe 10 questions to get a bronze medal, we can make it so that the gold medal only requires one question to get gold.
00:05:45 After you change that, go back to your profile and check this out.
00:05:48 We actually have one gold medal.
00:05:50 which means that not only the interactions are being tracked, but also the reputation system is recognizing them and attributing points to us.
00:05:58 For example, we also had a couple of answers.
00:06:01 So if I reduce the number of answers to one, maybe even the question upvotes or answers or the views, you'll see that we'll get a few more medals.
00:06:10 There we go, I'll mess with this entirely, just to see what we get.
00:06:14 And if I go back, we have four gold medals, three silvers, and three badges.
00:06:18 But this is way too easy, since we didn't really contribute that much.
00:06:21 The most important thing to note is that the system indeed works, so I'll bring these medals to a bit of a larger numbers,
00:06:28 and you surely can get this right from the first try.
00:06:31 Maybe you'll have to modify it a bit to increase the question count, to make it more difficult or easier to get specific badges attributed to their accounts.
00:06:42 But for now, I think this is totally fine.
00:06:44 Now that we know that everything is working, let's go ahead and commit it.
00:06:47 We must not forget to do that.
00:06:49 So I'll say implement badge assignment function.
00:06:55 Commit and sync.
00:06:58 Great work.