Build a loyalty program in Replit in 1-2 hours. Use Replit Agent to generate an Express + PostgreSQL app with a points economy — customers earn points on purchases, climb bronze/silver/gold/platinum tiers, and redeem rewards. Atomic redemption prevents double-spends. Deploy on Autoscale.
What you're building
A loyalty program turns one-time buyers into repeat customers. When customers know they're accumulating points toward a free product or VIP status, they choose your business over a competitor even when prices are similar. Airlines and coffee shops have used this psychology for decades — now you can build the same mechanics for your business in a few hours.
Replit Agent generates the entire Express backend with the points economy already wired up. The schema uses a double-entry bookkeeping pattern: every point event is a transaction row (positive for earned, negative for redeemed), and a PostgreSQL trigger maintains the running balance on the members table. This means you never manually update balances — the database handles it automatically and with perfect consistency.
The most important technical detail is the atomic redemption. Without a SELECT FOR UPDATE lock, two simultaneous redemption requests for the same member could both see a sufficient balance and both succeed, leaving the member with negative points. The redemption route uses a PostgreSQL transaction with a row-level lock to prevent this. Deploy on Autoscale — loyalty programs see traffic spikes aligned with your business's busy periods.
Final result
A fully functional loyalty program with a tiered points economy, rewards catalog with atomic redemption, member dashboard with tier progress, and an admin panel for rewards management — deployed on Replit Autoscale.
Tech stack
Prerequisites
- A Replit account (Free plan is sufficient)
- A list of reward items you want to offer (discounts, products, experiences)
- Tier names and minimum lifetime point thresholds (e.g., Bronze: 0, Silver: 500, Gold: 2000)
- Optional: an existing order system to call the /api/members/earn endpoint from
Build steps
Scaffold the project with Replit Agent
Create a new Repl and use the Agent prompt below to generate the full Express + PostgreSQL loyalty program with Drizzle schema, PostgreSQL trigger, routes, and React frontend in one shot.
1// Type this into Replit Agent:2// Build a loyalty program platform with Express and PostgreSQL using Drizzle ORM.3// Tables:4// - members: id serial pk, user_id text unique not null, name text, email text,5// points_balance integer not null default 0, lifetime_points integer not null default 0,6// tier text default 'bronze' (enum: bronze/silver/gold/platinum), joined_at timestamp default now()7// - point_transactions: id serial pk, member_id integer FK members not null,8// points integer not null (positive=earned, negative=redeemed), type text not null9// (enum: earned/redeemed/expired/adjusted/bonus), source text, description text,10// created_at timestamp default now()11// - rewards: id serial pk, name text not null, description text, points_cost integer not null,12// category text (enum: discount/product/experience/gift_card), image_url text,13// stock integer, is_active boolean default true, created_at timestamp default now()14// - redemptions: id serial pk, member_id integer FK members not null, reward_id integer FK rewards not null,15// points_spent integer not null, status text default 'pending'16// (enum: pending/fulfilled/cancelled), fulfilled_at timestamp, created_at timestamp default now()17// - tier_rules: id serial pk, tier text unique not null,18// min_lifetime_points integer not null, multiplier numeric not null default 1.0,19// perks jsonb20// Create a PostgreSQL trigger on point_transactions INSERT that updates21// members.points_balance (SUM of all points), members.lifetime_points (SUM of positive points),22// and recalculates the tier by checking lifetime_points against tier_rules.23// Routes: GET /api/members/me, GET /api/members/me/transactions,24// POST /api/members/earn (internal API), GET /api/rewards, POST /api/rewards/:id/redeem,25// GET /api/members/me/redemptions, GET /api/tiers, GET /api/admin/members,26// POST /api/admin/members/:id/adjust.27// Use Replit Auth. React frontend with member dashboard, rewards catalog,28// and admin panel. Bind server to 0.0.0.0.Pro tip: After Agent creates the tables, immediately seed the tier_rules table with your four tiers using Drizzle Studio. The trigger won't work correctly until tier_rules has data.
Expected result: A running Express app with all tables, the PostgreSQL trigger, and a React frontend. Drizzle Studio shows the trigger under the database's functions list.
Add the PostgreSQL trigger for automatic balance updates
The trigger fires after every INSERT into point_transactions and updates members.points_balance, lifetime_points, and tier automatically. Run this SQL in the Replit SQL Editor.
1-- Run in Replit SQL Editor after tables are created2CREATE OR REPLACE FUNCTION update_member_points()3RETURNS TRIGGER AS $$4DECLARE5 new_balance INTEGER;6 new_lifetime INTEGER;7 new_tier TEXT;8BEGIN9 -- Recalculate balance and lifetime points from all transactions10 SELECT11 COALESCE(SUM(points), 0),12 COALESCE(SUM(CASE WHEN points > 0 THEN points ELSE 0 END), 0)13 INTO new_balance, new_lifetime14 FROM point_transactions15 WHERE member_id = NEW.member_id;1617 -- Determine tier based on lifetime points18 SELECT tier INTO new_tier19 FROM tier_rules20 WHERE min_lifetime_points <= new_lifetime21 ORDER BY min_lifetime_points DESC22 LIMIT 1;2324 -- Update the member row25 UPDATE members26 SET27 points_balance = new_balance,28 lifetime_points = new_lifetime,29 tier = COALESCE(new_tier, 'bronze')30 WHERE id = NEW.member_id;3132 RETURN NEW;33END;34$$ LANGUAGE plpgsql;3536CREATE TRIGGER trg_update_member_points37AFTER INSERT ON point_transactions38FOR EACH ROW EXECUTE FUNCTION update_member_points();Expected result: After inserting a row into point_transactions, the corresponding member row shows updated points_balance and lifetime_points. The tier field changes automatically when lifetime_points crosses a tier threshold.
Build the earn and atomic redemption routes
The earn route is called by your order system to award points. The redemption route uses SELECT FOR UPDATE to prevent two concurrent requests from double-spending the same points balance.
1const express = require('express');2const { db } = require('../db');3const { members, pointTransactions, rewards, redemptions } = require('../../shared/schema');4const { eq, sql } = require('drizzle-orm');56const router = express.Router();78// POST /api/members/earn — called by your order system9// Body: { userId, basePoints, source } e.g. { userId: 'u_123', basePoints: 50, source: 'order-789' }10router.post('/members/earn', async (req, res) => {11 const { userId, basePoints, source } = req.body;1213 // Find or create member14 let [member] = await db.select().from(members).where(eq(members.userId, userId));15 if (!member) {16 [member] = await db.insert(members).values({ userId }).returning();17 }1819 // Apply tier multiplier20 const multiplierResult = await db.execute(21 sql`SELECT multiplier FROM tier_rules WHERE tier = ${member.tier} LIMIT 1`22 );23 const multiplier = parseFloat(multiplierResult.rows[0]?.multiplier || '1.0');24 const earnedPoints = Math.floor(basePoints * multiplier);2526 await db.insert(pointTransactions).values({27 memberId: member.id,28 points: earnedPoints,29 type: 'earned',30 source: source || null,31 description: `Earned ${earnedPoints} points (${multiplier}x multiplier)`,32 });3334 res.json({ memberId: member.id, earnedPoints, multiplierApplied: multiplier });35});3637// POST /api/rewards/:id/redeem — atomic redemption with row lock38router.post('/rewards/:id/redeem', async (req, res) => {39 const userId = req.user?.id;40 if (!userId) return res.status(401).json({ error: 'Login required' });4142 const rewardId = parseInt(req.params.id);4344 try {45 await db.execute(sql`BEGIN`);4647 // Lock the member row to prevent concurrent redemptions48 const memberResult = await db.execute(49 sql`SELECT * FROM members WHERE user_id = ${userId} FOR UPDATE`50 );51 const member = memberResult.rows[0];52 if (!member) {53 await db.execute(sql`ROLLBACK`);54 return res.status(404).json({ error: 'Member not found' });55 }5657 const rewardResult = await db.execute(58 sql`SELECT * FROM rewards WHERE id = ${rewardId} AND is_active = true FOR UPDATE`59 );60 const reward = rewardResult.rows[0];61 if (!reward) {62 await db.execute(sql`ROLLBACK`);63 return res.status(404).json({ error: 'Reward not found or unavailable' });64 }6566 if (member.points_balance < reward.points_cost) {67 await db.execute(sql`ROLLBACK`);68 return res.status(400).json({69 error: 'Insufficient points',70 balance: member.points_balance,71 required: reward.points_cost,72 });73 }7475 if (reward.stock !== null && reward.stock <= 0) {76 await db.execute(sql`ROLLBACK`);77 return res.status(400).json({ error: 'Reward out of stock' });78 }7980 // Deduct points (trigger updates balance)81 await db.execute(82 sql`INSERT INTO point_transactions (member_id, points, type, source, description)83 VALUES (${member.id}, ${-reward.points_cost}, 'redeemed',84 ${'reward-' + rewardId}, ${'Redeemed: ' + reward.name})`85 );8687 // Create redemption record88 const redemptionResult = await db.execute(89 sql`INSERT INTO redemptions (member_id, reward_id, points_spent)90 VALUES (${member.id}, ${rewardId}, ${reward.points_cost}) RETURNING *`91 );9293 // Decrement stock if tracked94 if (reward.stock !== null) {95 await db.execute(sql`UPDATE rewards SET stock = stock - 1 WHERE id = ${rewardId}`);96 }9798 await db.execute(sql`COMMIT`);99 res.status(201).json(redemptionResult.rows[0]);100 } catch (err) {101 await db.execute(sql`ROLLBACK`);102 res.status(500).json({ error: err.message });103 }104});105106module.exports = router;Expected result: POST /api/members/earn with basePoints: 100 for a Gold member (2x multiplier) inserts 200 points. The trigger updates members.points_balance to reflect the new total. POST /api/rewards/1/redeem fails with 400 if balance is insufficient.
Build the member dashboard and rewards catalog
The member endpoint returns everything the dashboard needs in one query. The rewards endpoint filters to show only items the current member can afford, making the catalog feel attainable.
1// GET /api/members/me — full member profile for dashboard2router.get('/members/me', async (req, res) => {3 const userId = req.user?.id;4 if (!userId) return res.status(401).json({ error: 'Login required' });56 const result = await db.execute(sql`7 SELECT8 m.*,9 tr.min_lifetime_points AS current_tier_min,10 tr.multiplier AS current_multiplier,11 tr.perks AS current_perks,12 next_tr.tier AS next_tier,13 next_tr.min_lifetime_points AS next_tier_min,14 (next_tr.min_lifetime_points - m.lifetime_points) AS points_to_next_tier15 FROM members m16 LEFT JOIN tier_rules tr ON tr.tier = m.tier17 LEFT JOIN tier_rules next_tr ON next_tr.min_lifetime_points > m.lifetime_points18 AND next_tr.min_lifetime_points = (19 SELECT MIN(min_lifetime_points) FROM tier_rules20 WHERE min_lifetime_points > m.lifetime_points21 )22 WHERE m.user_id = ${userId}23 `);2425 if (!result.rows[0]) {26 // Auto-create member on first login27 const [newMember] = await db.insert(members).values({ userId }).returning();28 return res.json({ ...newMember, next_tier: 'silver', next_tier_min: 500, points_to_next_tier: 500 });29 }3031 res.json(result.rows[0]);32});3334// GET /api/rewards — rewards catalog35router.get('/rewards', async (req, res) => {36 const rows = await db.execute(sql`37 SELECT r.*,38 CASE WHEN ${req.user?.id} IS NOT NULL THEN39 r.points_cost <= COALESCE((40 SELECT points_balance FROM members WHERE user_id = ${req.user?.id || ''}41 ), 0)42 ELSE false END AS can_afford43 FROM rewards r44 WHERE r.is_active = true45 ORDER BY r.points_cost ASC46 `);47 res.json(rows.rows);48});Expected result: GET /api/members/me returns points_balance, tier, next_tier, and points_to_next_tier. GET /api/rewards includes a can_afford boolean on each reward so the frontend can disable the Redeem button for unaffordable items.
Seed tier rules and deploy on Autoscale
Seed the tier_rules table with your four tiers before deploying. Then deploy on Autoscale — loyalty programs have usage patterns aligned with business hours rather than constant 24/7 traffic.
1// scripts/seed-tiers.js — run once to set up tier rules2const { db } = require('../server/db');3const { tierRules } = require('./shared/schema');45async function seedTiers() {6 await db.insert(tierRules).values([7 {8 tier: 'bronze',9 minLifetimePoints: 0,10 multiplier: '1.0',11 perks: JSON.stringify(['Earn 1 point per dollar', 'Birthday bonus points']),12 },13 {14 tier: 'silver',15 minLifetimePoints: 500,16 multiplier: '1.5',17 perks: JSON.stringify(['Earn 1.5 points per dollar', 'Free shipping on redemptions', 'Early access to new rewards']),18 },19 {20 tier: 'gold',21 minLifetimePoints: 2000,22 multiplier: '2.0',23 perks: JSON.stringify(['Earn 2 points per dollar', 'Double points on birthday month', 'Priority support']),24 },25 {26 tier: 'platinum',27 minLifetimePoints: 10000,28 multiplier: '3.0',29 perks: JSON.stringify(['Earn 3 points per dollar', 'Exclusive platinum rewards', 'Dedicated account manager']),30 },31 ]).onConflictDoNothing();3233 console.log('Tier rules seeded successfully');34 process.exit(0);35}3637seedTiers().catch(console.error);Pro tip: To deploy: click the Deploy button in Replit, choose Autoscale, and set the run command to node server/index.js. Autoscale scales down during quiet hours and up during your business's peak times automatically.
Expected result: The tier_rules table shows four rows. Members who earn 500 lifetime points automatically upgrade to Silver tier because the PostgreSQL trigger recalculates the tier on every transaction.
Complete code
1const express = require('express');2const { db } = require('../db');3const { members, pointTransactions, rewards, redemptions, tierRules } = require('../../shared/schema');4const { eq, sql } = require('drizzle-orm');56const router = express.Router();78// POST /api/members/earn9router.post('/members/earn', async (req, res) => {10 const { userId, basePoints, source, description } = req.body;11 if (!userId || !basePoints) return res.status(400).json({ error: 'userId and basePoints required' });1213 let [member] = await db.select().from(members).where(eq(members.userId, userId));14 if (!member) {15 [member] = await db.insert(members).values({ userId }).returning();16 }1718 const multiplierResult = await db.execute(19 sql`SELECT multiplier FROM tier_rules WHERE tier = ${member.tier} LIMIT 1`20 );21 const multiplier = parseFloat(multiplierResult.rows[0]?.multiplier || '1.0');22 const earnedPoints = Math.floor(basePoints * multiplier);2324 await db.insert(pointTransactions).values({25 memberId: member.id,26 points: earnedPoints,27 type: 'earned',28 source: source || null,29 description: description || `Earned ${earnedPoints} pts`,30 });3132 res.json({ memberId: member.id, earnedPoints, multiplier });33});3435// POST /api/rewards/:id/redeem — atomic with FOR UPDATE lock36router.post('/rewards/:id/redeem', async (req, res) => {37 const userId = req.user?.id;38 if (!userId) return res.status(401).json({ error: 'Login required' });39 const rewardId = parseInt(req.params.id);4041 try {42 await db.execute(sql`BEGIN`);4344 const mr = await db.execute(sql`SELECT * FROM members WHERE user_id = ${userId} FOR UPDATE`);45 const rr = await db.execute(sql`SELECT * FROM rewards WHERE id = ${rewardId} AND is_active = true FOR UPDATE`);46 const member = mr.rows[0];47 const reward = rr.rows[0];4849 if (!member) { await db.execute(sql`ROLLBACK`); return res.status(404).json({ error: 'Member not found' }); }50 if (!reward) { await db.execute(sql`ROLLBACK`); return res.status(404).json({ error: 'Reward not found' }); }51 if (member.points_balance < reward.points_cost) {52 await db.execute(sql`ROLLBACK`);53 return res.status(400).json({ error: 'Insufficient points', balance: member.points_balance, required: reward.points_cost });54 }55 if (reward.stock !== null && reward.stock <= 0) { await db.execute(sql`ROLLBACK`); return res.status(400).json({ error: 'Out of stock' }); }5657 await db.execute(sql`INSERT INTO point_transactions (member_id, points, type, source, description) VALUES (${member.id}, ${-reward.points_cost}, 'redeemed', ${'reward-' + rewardId}, ${'Redeemed: ' + reward.name})`);58 const result = await db.execute(sql`INSERT INTO redemptions (member_id, reward_id, points_spent) VALUES (${member.id}, ${rewardId}, ${reward.points_cost}) RETURNING *`);59 if (reward.stock !== null) await db.execute(sql`UPDATE rewards SET stock = stock - 1 WHERE id = ${rewardId}`);6061module.exports = router;Customization ideas
Birthday bonus points
Add a birthday column to members. A daily Scheduled Deployment checks for members whose birthday is today and inserts a bonus point transaction with type = 'bonus'. Gold and Platinum tiers get a 2x birthday bonus.
Referral program
Add a referrals table with referrer_id and referee_id columns. When a new member joins via a referral link, insert earn transactions for both the referrer (e.g., 200 points) and the referee (e.g., 100 welcome points).
Point expiry
Add an expires_at column to point_transactions. A daily Scheduled Deployment finds transactions where expires_at < now() and points have not yet expired, then inserts a negative 'expired' transaction to remove them from the balance.
Stripe checkout integration
Call POST /api/members/earn from a Stripe webhook handler after checkout.session.completed fires. Calculate base points as (session.amount_total / 100) and pass the session ID as the source field for traceability.
Common pitfalls
Pitfall: Updating points_balance directly instead of inserting a transaction
How to avoid: Always insert a row into point_transactions. The PostgreSQL trigger recalculates the balance as a SUM of all transactions, keeping the audit trail and balance permanently in sync.
Pitfall: Not using SELECT FOR UPDATE in the redemption route
How to avoid: The redemption route wraps the full operation in a transaction with SELECT ... FOR UPDATE on the member row. The second request waits for the first to commit before reading the balance.
Pitfall: Forgetting to seed tier_rules before the trigger fires
How to avoid: Run scripts/seed-tiers.js immediately after deploying the schema. Verify the tier_rules table has four rows in Drizzle Studio before inserting any point transactions.
Pitfall: Calculating tier based on points_balance instead of lifetime_points
How to avoid: The trigger always recalculates tier from lifetime_points (sum of all positive transactions), which only increases. Redemptions reduce points_balance but never affect tier.
Best practices
- Use the PostgreSQL trigger for balance and tier updates — never update members.points_balance directly from application code.
- Always use SELECT FOR UPDATE in the redemption route to prevent concurrent double-spends.
- Calculate tier from lifetime_points (cumulative earnings), not points_balance (current balance after redemptions).
- Use Drizzle Studio to seed tier_rules before running any point transactions — the trigger depends on these rows.
- Log every point event to point_transactions with a meaningful source field (e.g., 'order-789') for auditability and dispute resolution.
- Deploy on Autoscale — loyalty programs have traffic peaks aligned with business hours and Autoscale handles them cost-effectively.
- Store no external API keys in Secrets unless integrating payment processing. The loyalty engine itself needs no third-party services.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a loyalty program with Express and PostgreSQL. I have a point_transactions table (member_id, points integer — positive for earned/negative for redeemed) and a members table (points_balance, lifetime_points, tier). Help me write a PostgreSQL trigger function that fires after INSERT on point_transactions and: (1) recalculates points_balance as SUM of all points for that member, (2) recalculates lifetime_points as SUM of all positive points, (3) looks up the correct tier from a tier_rules table where min_lifetime_points <= lifetime_points ORDER BY min_lifetime_points DESC, and (4) updates the members row atomically.
Add a points expiry system to the loyalty program. Add an expires_at column to point_transactions (NULL means no expiry). Create a Replit Scheduled Deployment that runs daily at midnight: it queries SELECT member_id, SUM(points) AS expiring FROM point_transactions WHERE expires_at IS NOT NULL AND expires_at < NOW() AND type != 'expired' GROUP BY member_id, then for each member inserts a negative transaction with type = 'expired' and points = -expiring_sum to remove the expired points. The trigger automatically updates the balance. Send an email warning via SendGrid 7 days before expiry using a second daily query.
Frequently asked questions
How do I connect this to my existing checkout system?
Call POST /api/members/earn from your checkout system after a purchase is confirmed. Send the user's ID, the base points to award (e.g., 1 point per dollar: amount_cents / 100), and the order ID as the source. The loyalty API handles multipliers and tier updates automatically.
Can members have negative points after a redemption?
No. The redemption route checks points_balance >= points_cost before deducting. The SELECT FOR UPDATE lock ensures this check and the deduction happen atomically — no concurrent request can slip through between the check and the update.
What happens to tier when a member redeems a lot of points?
Nothing — tier is calculated from lifetime_points (total earned, never decreases), not points_balance (current spendable balance). A Platinum member who redeems 10,000 points stays Platinum because their lifetime_points remain above the threshold.
What Replit plan do I need?
The Free plan is sufficient for development. For a public-facing loyalty program, deploy on Autoscale (Core plan or higher). Autoscale scales to zero during quiet periods and handles traffic spikes during your business's peak hours.
How do I handle the admin adjusting a member's points?
The POST /api/admin/members/:id/adjust route inserts a point_transactions row with type = 'adjusted', the adjustment amount (positive or negative), and the admin's name as the source. The trigger updates the balance. Every adjustment is logged in the transaction history for the member to see.
Can I add a point expiry system?
Yes. Add an expires_at column to point_transactions. Create a Replit Scheduled Deployment that runs daily: it finds transactions where expires_at < now() and inserts a corresponding negative 'expired' transaction to remove those points. The trigger automatically reduces the member's balance.
Can RapidDev help build a custom loyalty program?
Yes. RapidDev has built 600+ apps including e-commerce platforms and customer engagement tools. They can add custom earning rules, points multiplier campaigns, referral programs, and integrations with your existing checkout system. Book a free consultation at rapidevelopers.com.
What if two customers redeem the last unit of a reward simultaneously?
The SELECT FOR UPDATE lock on the rewards row prevents this. The first request locks the row, checks stock > 0, and decrements it. The second request waits for the first to commit, then reads stock = 0 and returns a 400 Out of Stock response.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation