How to Build a Loyalty and Rewards App in 2026: Points, Tiers, and Real Engagement
Every coffee shop has a loyalty card. Most of them are forgotten in wallets after two visits. The ones that work - Starbucks, Sephora's Beauty Insider, airline miles - drive enormous repeat purchase and customer lifetime value.
The difference isn't the points. It's the mechanics: how points are earned, what they can buy, how close the customer is to the next reward, and whether the program creates genuine value or just feels like a coupon clipper.
This guide covers the full technical implementation plus the product decisions that determine whether your loyalty program is actually used.
The core product decisions (before any code)
What earns points?
Every action you reward changes behavior. Define exactly what you want customers to do more of:
- Purchases (the default, but consider: all purchases equally, or bonus categories?)
- Referrals (high-value, one-time behavior per relationship)
- Reviews (valuable for social proof, one-time per purchase)
- Profile completion (one-time engagement hook)
- Birthdays / anniversaries (high-margin, feels personal)
- Social shares (mixed results, often low quality engagement)
- App installs / enabling notifications (useful for retention)
Avoid rewarding behaviors that don't correlate with value. Rewarding "open the app" leads to opens with no purchase. Rewarding purchases leads to purchases.
What do points buy?
The redemption side determines program perceived value:
- Discounts on future purchases (most common, direct economic value)
- Free products (feels more rewarding than equivalent discount)
- Exclusive products (scarcity creates aspiration)
- Early access (non-monetary, high perceived value for engaged customers)
- Experiences (dinner with chef, factory tour - high brand impact)
- Charitable donation (segment of customers prefers this)
One decision that matters significantly: expiry. Points that expire create urgency (good for engagement) but also frustration (bad for brand perception). The industry is moving toward no-expiry with tier maintenance requirements instead.
Tiers: do you need them?
Tiers (Bronze/Silver/Gold, or naming like Starbucks's Green/Gold) add aspiration and segment your customer base. The best customers get better benefits, which:
- Increases retention at the top tier
- Gives aspirational users a visible goal
- Lets you offer economics (free upgrades, exclusive access) only to customers who deserve them
Tiers require annual (or rolling) spend thresholds to maintain. "You earned Gold status this year" is motivating. "You lost Gold status" is a retention risk - handle these transitions carefully.
Data model
// Core entities
User
βββ id
βββ loyaltyTier: bronze | silver | gold | platinum
βββ tierQualifyingSpend (rolling 12 months)
βββ tierValidUntil: Date
βββ totalLifetimeSpend
PointsLedger (append-only, never update/delete)
βββ id
βββ userId
βββ type: earn | redeem | expire | adjustment | referral_bonus
βββ points (positive = earn, negative = redeem/expire)
βββ balance (running balance after this transaction)
βββ referenceId (orderId, reviewId, etc.)
βββ referenceType
βββ description
βββ expiresAt (if applicable)
βββ createdAt
PointsBalance (derived, updated on each ledger entry)
βββ userId (PK)
βββ availablePoints
βββ pendingPoints (earned but not yet vested)
βββ lifetimePoints
βββ lastUpdatedAt
Reward
βββ id
βββ name, description
βββ type: discount | product | experience | donation
βββ pointsCost
βββ discount (if type=discount): amount or percentage
βββ productId (if type=product)
βββ stock (null = unlimited)
βββ requiredTier: null | bronze | silver | gold | platinum
βββ validFrom, validUntil
βββ active
Redemption
βββ id
βββ userId
βββ rewardId
βββ pointsSpent
βββ status: pending | fulfilled | cancelled
βββ couponCode (if type=discount)
βββ createdAt
Critical: the ledger pattern. Never update a points balance directly. Every change is an immutable ledger entry. This gives you:
- A full audit trail for disputes ("I should have more points")
- The ability to reconstruct the balance at any point in time
- No balance drift from concurrent updates
Points engine
class PointsEngine {
// Earn points (with pending period for return windows)
async earnPoints(userId, amount, reference, options = {}) {
const { pendingDays = 0, expiresInDays = null } = options;
const earnDate = new Date();
const vestDate = pendingDays > 0
? new Date(earnDate.getTime() + pendingDays * 86400000)
: earnDate;
const expiresAt = expiresInDays
? new Date(earnDate.getTime() + expiresInDays * 86400000)
: null;
await db.transaction(async (trx) => {
// If vesting immediately, update balance
if (pendingDays === 0) {
await trx('points_balance')
.where({ userId })
.increment('availablePoints', amount)
.increment('lifetimePoints', amount);
} else {
await trx('points_balance')
.where({ userId })
.increment('pendingPoints', amount)
.increment('lifetimePoints', amount);
}
await trx('points_ledger').insert({
userId,
type: 'earn',
points: amount,
referenceId: reference.id,
referenceType: reference.type,
status: pendingDays > 0 ? 'pending' : 'vested',
vestAt: vestDate,
expiresAt,
createdAt: earnDate,
});
});
// Check if tier upgrade is triggered
await this.evaluateTierUpgrade(userId);
// Send engagement notification if milestone reached
await this.checkMilestones(userId);
}
// Redeem points for a reward
async redeemPoints(userId, rewardId) {
const [balance, reward] = await Promise.all([
this.getBalance(userId),
Reward.findById(rewardId),
]);
if (!reward.active || (reward.validUntil && reward.validUntil < new Date())) {
throw new Error('Reward is not available');
}
if (balance.availablePoints < reward.pointsCost) {
throw new Error(`Insufficient points: have ${balance.availablePoints}, need ${reward.pointsCost}`);
}
const user = await User.findById(userId);
if (reward.requiredTier && TIER_RANK[user.loyaltyTier] < TIER_RANK[reward.requiredTier]) {
throw new Error(`Requires ${reward.requiredTier} tier`);
}
if (reward.stock !== null && reward.stock <= 0) {
throw new Error('Reward is out of stock');
}
return await db.transaction(async (trx) => {
// Deduct points
await trx('points_balance')
.where({ userId })
.decrement('availablePoints', reward.pointsCost);
await trx('points_ledger').insert({
userId,
type: 'redeem',
points: -reward.pointsCost,
referenceId: rewardId,
referenceType: 'reward',
createdAt: new Date(),
});
// Decrement stock if limited
if (reward.stock !== null) {
await trx('rewards').where({ id: rewardId }).decrement('stock', 1);
}
// Create redemption record
const couponCode = reward.type === 'discount'
? generateCouponCode()
: null;
const redemption = await trx('redemptions').insert({
userId,
rewardId,
pointsSpent: reward.pointsCost,
status: 'pending',
couponCode,
createdAt: new Date(),
}).returning('*');
return redemption[0];
});
}
// Vest pending points (run via cron after return window)
async vestPendingPoints(ledgerEntryId) {
const entry = await LedgerEntry.findById(ledgerEntryId);
if (entry.status !== 'pending' || entry.vestAt > new Date()) return;
await db.transaction(async (trx) => {
await trx('points_ledger').where({ id: ledgerEntryId }).update({ status: 'vested' });
await trx('points_balance')
.where({ userId: entry.userId })
.increment('availablePoints', entry.points)
.decrement('pendingPoints', entry.points);
});
}
}
Tier system
const TIERS = {
bronze: { minSpend: 0, pointsMultiplier: 1.0, color: '#CD7F32' },
silver: { minSpend: 5000, pointsMultiplier: 1.25, color: '#C0C0C0' },
gold: { minSpend: 15000, pointsMultiplier: 1.5, color: '#FFD700' },
platinum: { minSpend: 50000, pointsMultiplier: 2.0, color: '#E5E4E2' },
};
const TIER_RANK = { bronze: 0, silver: 1, gold: 2, platinum: 3 };
async function evaluateTierUpgrade(userId) {
const user = await User.findById(userId);
// Calculate qualifying spend in the last 12 months
const qualifyingSpend = await Order.sumByUser(userId, {
completedAfter: new Date(Date.now() - 365 * 86400000),
statuses: ['delivered', 'completed'],
});
// Find the highest tier the user qualifies for
const qualifiedTier = Object.entries(TIERS)
.reverse()
.find(([_, config]) => qualifyingSpend >= config.minSpend)?.[0] || 'bronze';
if (TIER_RANK[qualifiedTier] > TIER_RANK[user.loyaltyTier]) {
// Upgrade
await User.update(userId, {
loyaltyTier: qualifiedTier,
tierValidUntil: new Date(Date.now() + 365 * 86400000),
});
await notifications.send(userId, {
type: 'tier_upgrade',
newTier: qualifiedTier,
benefits: TIER_BENEFITS[qualifiedTier],
});
}
}
Engagement mechanics that actually work
Progress indicators
Show users exactly how close they are to the next reward or tier. "You're 150 points away from a free coffee" drives immediate behavior. "You have 2,847 points" does not.
// Calculate progress to next milestone
async function getProgressData(userId) {
const [balance, user] = await Promise.all([
pointsEngine.getBalance(userId),
User.findById(userId),
]);
// Progress to next reward
const nextReward = await Reward.findNextAffordable(balance.availablePoints);
// Progress to next tier
const currentTierConfig = TIERS[user.loyaltyTier];
const tierKeys = Object.keys(TIERS);
const currentTierIndex = tierKeys.indexOf(user.loyaltyTier);
const nextTier = tierKeys[currentTierIndex + 1];
const nextTierConfig = nextTier ? TIERS[nextTier] : null;
return {
availablePoints: balance.availablePoints,
pendingPoints: balance.pendingPoints,
currentTier: user.loyaltyTier,
nextReward: nextReward ? {
name: nextReward.name,
pointsNeeded: nextReward.pointsCost - balance.availablePoints,
progress: Math.round((balance.availablePoints / nextReward.pointsCost) * 100),
} : null,
tierProgress: nextTierConfig ? {
nextTier,
spendNeeded: nextTierConfig.minSpend - user.tierQualifyingSpend,
progress: Math.round((user.tierQualifyingSpend / nextTierConfig.minSpend) * 100),
} : null,
};
}
Push notifications that don't annoy
// Good: actionable, personalized, timely
const NOTIFICATION_TYPES = {
points_earned: {
title: 'Points earned!',
body: (data) => `You earned ${data.points} points on your order. Balance: ${data.newBalance}`,
send: true,
},
near_reward: {
title: 'Almost there!',
body: (data) => `Just ${data.pointsNeeded} points to get a free ${data.rewardName}`,
// Only send when < 20% away from a reward
condition: (data) => data.progressPercent >= 80,
},
tier_expiry_warning: {
title: `Your ${data.tier} status expires soon`,
body: (data) => `Spend $${data.amountNeeded} more to keep your ${data.tier} benefits`,
// 30 days before expiry
},
points_expiry_warning: {
// 14 days before expiry
body: (data) => `${data.expiringPoints} points expire in 14 days. Redeem now`,
},
};
Birthday/anniversary moments
High-perceived-value, low-cost. A bonus points event on a user's birthday costs very little but creates strong positive emotion.
// Cron: runs daily, check for birthdays
async function processBirthdayBonuses() {
const today = new Date();
const month = today.getMonth() + 1;
const day = today.getDate();
const usersWithBirthday = await User.findByBirthday(month, day);
for (const user of usersWithBirthday) {
await pointsEngine.earnPoints(user.id, BIRTHDAY_BONUS_POINTS, {
type: 'birthday_bonus',
id: `birthday_${user.id}_${today.getFullYear()}`,
});
await notifications.send(user.id, { type: 'birthday_bonus', points: BIRTHDAY_BONUS_POINTS });
}
}
Common mistakes
Points that are too hard to earn. If a user needs 50 purchases to get a free $5 item, they'll disengage after 3 purchases. The first reward should be achievable within 3-5 normal purchases.
Too complex to understand. If users need to read a FAQ to know how many points they have and what they can do with them, the program has failed. The loyalty screen should communicate status, progress, and next action in under 5 seconds.
No redemption path. Many programs focus on earning but make redemption confusing or limited. If spending points is hard, users stop caring about earning them.
Rewarding bad behavior. Rewarding refunds, rewarding cheap SKUs to farm points, rewarding accounts created for the sole purpose of referral farming. Model your program economics before launch. How much does it cost to issue 1 point? What's the expected redemption rate? What's the cost per acquired repeat customer?
Ignoring fraud. Loyalty programs are fraud targets: fake referrals, account sharing, promotional abuse. Build basic fraud detection from launch: flag unusual earning velocity, require purchase verification before large redemptions, limit referral bonuses per account.
What to build first
MVP that works:
- Points earning on purchases (integer points, simple ratio)
- Points balance visible in app
- One type of redemption (discount on next order)
- Push notification when points are earned
- Progress indicator toward first reward
Skip for MVP:
- Tiers
- Complex earning rules (bonus categories, multipliers)
- Social features
- Partner points (earn at third parties)
- Physical loyalty cards
A working simple program beats a complex program that users don't understand. Start with one reward type, one earning rate, one way to see your balance. Iterate based on actual redemption data.
Aunimeda builds software products for businesses - websites, mobile apps, automation systems, and custom platforms.
Contact us to discuss your project. See also: Web Development, Mobile App Development