Build a loyalty points and rewards system in Lovable where customers earn points on purchases, auto-upgrade through tiers via a Postgres trigger, redeem rewards atomically using SELECT FOR UPDATE in an Edge Function, and manage a reward catalog — all backed by Supabase with full RLS and a real-time points balance dashboard.
What you're building
A loyalty program lives or dies by point balance integrity. The design here keeps a point_transactions ledger rather than a mutable balance column — each earn or redeem is an immutable INSERT. The customer's real balance is always a SUM query on their transactions. This prevents silent data corruption and gives you a full audit trail.
Tier calculation runs as a Postgres AFTER INSERT trigger on point_transactions. Whenever a new earn transaction is inserted, the trigger recalculates the customer's lifetime earned points and updates their tier column in the profiles table. Tiers are: Bronze (0–499), Silver (500–1999), Gold (2000+). Because the trigger runs inside the same transaction as the point insert, tier and balance are always consistent.
Redemptions are the tricky part. Two customers could try to redeem the last available reward simultaneously. The Edge Function wraps the check-and-decrement in a Postgres transaction with SELECT FOR UPDATE on the rewards row. This locks the row during the check, preventing a second concurrent request from reading the old stock count before the first has decremented it.
Final result
A complete loyalty program where points are tamper-proof, tiers update automatically, redemptions are race-condition safe, and customers see their progress in real time.
Tech stack
Prerequisites
- Lovable Pro account for Edge Function generation
- Supabase project with SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY saved to Cloud tab → Secrets
- An existing users or profiles table, or willingness to create one in this build
- Basic understanding of how points-based loyalty programs work (earn on purchase, redeem for rewards)
- Optional: a products or orders table to trigger point earning from real purchases
Build steps
Create the loyalty schema with tier trigger
Start by asking Lovable to create the complete schema. The tier trigger is the most important piece — it must fire on every new earn transaction and recalculate the customer's tier using a CASE statement on their lifetime points.
1Create a loyalty program database schema in Supabase.23Tables:4- profiles: extend with columns tier (text default 'Bronze'), lifetime_points (int default 0), updated_at5- rewards: id, name, description, point_cost (int), stock (int), image_url, is_active (bool default true), created_at6- point_transactions: id, user_id (references auth.users), type ('earn' | 'redeem'), points (int, positive for earn, negative for redeem), description, reference_id (text, optional order/reward id), created_at7- redemptions: id, user_id, reward_id (references rewards), points_spent (int), status ('pending' | 'fulfilled' | 'cancelled'), created_at89Create a Postgres trigger function update_tier_after_earn() that fires AFTER INSERT on point_transactions WHERE NEW.type = 'earn':101. SELECT SUM(points) FROM point_transactions WHERE user_id = NEW.user_id AND type = 'earn' INTO lifetime_pts112. UPDATE profiles SET lifetime_points = lifetime_pts, tier = CASE WHEN lifetime_pts >= 2000 THEN 'Gold' WHEN lifetime_pts >= 500 THEN 'Silver' ELSE 'Bronze' END WHERE id = NEW.user_id1213RLS policies:14- point_transactions: users SELECT/INSERT their own rows (user_id = auth.uid())15- rewards: public SELECT, admin-only INSERT/UPDATE/DELETE16- redemptions: users SELECT their own, service role INSERT17- profiles: users SELECT/UPDATE their own rowPro tip: Ask Lovable to add a partial index on point_transactions: CREATE INDEX idx_point_tx_earn ON point_transactions(user_id) WHERE type = 'earn'. The tier trigger SUM query will use this index instead of scanning all transaction types.
Expected result: All four tables are created. The tier trigger is attached to point_transactions. Inserting an earn row of 600 points auto-updates the profile tier to Silver. TypeScript types are generated.
Build the atomic redemption Edge Function
This is the core of your loyalty system's integrity. The Edge Function runs a Postgres transaction that locks the reward row, checks stock and customer balance, then performs both decrements atomically. Ask Lovable to generate it from this specification.
1// supabase/functions/redeem-reward/index.ts2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'45const corsHeaders = {6 'Access-Control-Allow-Origin': '*',7 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',8 'Content-Type': 'application/json',9}1011serve(async (req: Request) => {12 if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })1314 try {15 const supabase = createClient(16 Deno.env.get('SUPABASE_URL') ?? '',17 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''18 )1920 const { reward_id, user_id } = await req.json()21 if (!reward_id || !user_id) {22 return new Response(JSON.stringify({ error: 'reward_id and user_id required' }), { status: 400, headers: corsHeaders })23 }2425 // Run atomic redemption via RPC (Postgres function with transaction)26 const { data, error } = await supabase.rpc('atomic_redeem_reward', {27 p_reward_id: reward_id,28 p_user_id: user_id,29 })3031 if (error) {32 return new Response(JSON.stringify({ error: error.message }), { status: 400, headers: corsHeaders })33 }3435 return new Response(JSON.stringify({ success: true, redemption: data }), { headers: corsHeaders })36 } catch (err) {37 return new Response(JSON.stringify({ error: 'Internal error' }), { status: 500, headers: corsHeaders })38 }39})Pro tip: The real atomic work happens in a Postgres function atomic_redeem_reward(). Ask Lovable to create it with LANGUAGE plpgsql SECURITY DEFINER. Use SELECT ... FOR UPDATE on the rewards row and RAISE EXCEPTION for insufficient balance or out-of-stock to trigger an automatic rollback.
Expected result: The Edge Function is deployed. Calling it with a valid reward_id and sufficient balance creates a redemption row and deducts points. Concurrent calls for the last item in stock result in exactly one success and one 'out of stock' error.
Build the customer loyalty dashboard
Ask Lovable to create the main page customers see: their current balance prominently displayed, tier status with a progress bar, and a transaction history.
1Build a customer loyalty dashboard at src/pages/Dashboard.tsx.23Layout:4- Top row: three stat Cards side by side5 - Card 1: 'Your Points' — show current balance (SUM of all transactions for auth user), large number6 - Card 2: 'Your Tier' — show tier as a colored Badge (Bronze=amber, Silver=gray, Gold=yellow) with a Crown icon for Gold7 - Card 3: 'Lifetime Earned' — show total earn-type points8- Below stats: a Progress bar showing points toward next tier. Label: 'X more points to Silver' (or Gold). Calculate from lifetime_points in profiles.9- Below progress: a Card containing a DataTable of point_transactions ordered by created_at DESC. Columns: Date (relative), Description, Points (green for earn, red for redeem), Running Balance. Show 20 rows with pagination.10- Use Recharts AreaChart showing points earned per week for the last 8 weeks. Fetch grouped data from point_transactions WHERE type = 'earn'.1112All data fetches use the Supabase client with the authenticated user's session. The balance SUM and the profile tier come from separate queries that run in parallel with Promise.all.Expected result: The dashboard shows the customer's balance, tier, progress bar, and transaction history. The area chart renders points earned per week. All data is scoped to the authenticated user via RLS.
Build the reward catalog
Customers need a page to browse and redeem available rewards. Ask Lovable for a card grid that shows reward details and enables redemption with a confirmation Dialog.
1Build a rewards catalog page at src/pages/Rewards.tsx.23Requirements:4- Fetch all rewards WHERE is_active = true, ordered by point_cost ASC5- Display as a responsive grid of Cards (3 columns desktop, 2 tablet, 1 mobile)6- Each Card shows: reward image (AspectRatio), name, description, point_cost as a Badge with Coins icon, stock remaining (show 'Low stock' warning Badge if stock < 5), and a 'Redeem' Button7- The Redeem Button is disabled if the customer's current balance < point_cost. Show a Tooltip explaining why it is disabled.8- Clicking Redeem opens an AlertDialog: 'Redeem [reward name] for [X] points?' with Confirm and Cancel buttons9- On confirm, call the redeem-reward Edge Function via supabase.functions.invoke('redeem-reward', { body: { reward_id, user_id } })10- Show a success Toast ('Reward redeemed! Check your email for details.') or error Toast on completion11- After successful redemption, invalidate and refetch the customer's point balanceExpected result: The rewards catalog shows all active rewards as cards. The redeem button is correctly disabled for unaffordable rewards. Confirming a redemption calls the Edge Function and updates the balance.
Build the admin reward management page
Admins need to add new rewards, adjust stock, and see customer point balances. Ask Lovable to build a protected admin area.
1Build an admin page at src/pages/Admin.tsx. Protect it so only users with role='admin' in profiles can access it (check role on mount, redirect to / if not admin).23Two Tabs: 'Rewards' and 'Customers'.45Rewards tab:6- DataTable of all rewards with columns: Name, Point Cost, Stock, Active Toggle (Switch component), Edit Button7- 'Add Reward' Button opens a Sheet with a form: name (Input), description (Textarea), point_cost (Input number), stock (Input number), image_url (Input), is_active (Switch). Submit inserts via supabase.from('rewards').insert()8- Clicking Edit opens the same Sheet pre-filled for updating910Customers tab:11- DataTable fetching profiles joined with point balance SUM. Columns: Email (from auth.users), Tier Badge, Balance, Lifetime Points, Joined Date12- Input to manually add points: select customer, enter points and description, click 'Add Points' to INSERT into point_transactions with type='earn' using service role (call an Edge Function add-points that validates admin role server-side)Expected result: Admin page is only visible to admin-role users. Rewards can be created and edited. The customers tab shows all users with their current balances. Manual point addition works via the Edge Function.
Complete code
1import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'2import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'34const corsHeaders = {5 'Access-Control-Allow-Origin': '*',6 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',7 'Content-Type': 'application/json',8}910serve(async (req: Request) => {11 if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })1213 const supabase = createClient(14 Deno.env.get('SUPABASE_URL') ?? '',15 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''16 )1718 try {19 const { reward_id, user_id } = await req.json()2021 if (!reward_id || !user_id) {22 return new Response(23 JSON.stringify({ error: 'reward_id and user_id are required' }),24 { status: 400, headers: corsHeaders }25 )26 }2728 // Atomic redemption: lock reward row, check balance, decrement stock, insert transaction29 const { data, error } = await supabase.rpc('atomic_redeem_reward', {30 p_reward_id: reward_id,31 p_user_id: user_id,32 })3334 if (error) {35 const status = error.message.includes('Insufficient') || error.message.includes('stock') ? 400 : 50036 return new Response(JSON.stringify({ error: error.message }), { status, headers: corsHeaders })37 }3839 return new Response(40 JSON.stringify({ success: true, redemption_id: data }),41 { status: 200, headers: corsHeaders }42 )43 } catch (err) {44 const message = err instanceof Error ? err.message : 'Internal server error'45 return new Response(JSON.stringify({ error: message }), { status: 500, headers: corsHeaders })46 }47})4849/*50Postgres function for Lovable to create:5152CREATE OR REPLACE FUNCTION atomic_redeem_reward(53 p_reward_id uuid,54 p_user_id uuid55) RETURNS uuid56LANGUAGE plpgsql57SECURITY DEFINER58AS $$59DECLARE60 v_reward rewards%ROWTYPE;61 v_balance int;62 v_redemption_id uuid;63BEGIN64 SELECT * INTO v_reward FROM rewards WHERE id = p_reward_id FOR UPDATE;65 IF NOT FOUND THEN RAISE EXCEPTION 'Reward not found'; END IF;66 IF v_reward.stock <= 0 THEN RAISE EXCEPTION 'Out of stock'; END IF;67 SELECT COALESCE(SUM(points), 0) INTO v_balance68 FROM point_transactions WHERE user_id = p_user_id;69 IF v_balance < v_reward.point_cost THEN70 RAISE EXCEPTION 'Insufficient points: need %, have %', v_reward.point_cost, v_balance;71 END IF;72 UPDATE rewards SET stock = stock - 1 WHERE id = p_reward_id;73 INSERT INTO point_transactions (user_id, type, points, description, reference_id)74 VALUES (p_user_id, 'redeem', -v_reward.point_cost, 'Redeemed: ' || v_reward.name, p_reward_id);75 INSERT INTO redemptions (user_id, reward_id, points_spent, status)76 VALUES (p_user_id, p_reward_id, v_reward.point_cost, 'pending')77 RETURNING id INTO v_redemption_id;78 RETURN v_redemption_id;79END;80$$;81*/Customization ideas
Point expiry system
Add an expires_at column to point_transactions. Modify the balance SUM query to exclude expired rows. Create a Supabase scheduled Edge Function that runs nightly to insert automatic 'expiry' transactions for points older than 12 months, maintaining the immutable ledger pattern.
Bonus point campaigns
Add a campaigns table with start_date, end_date, multiplier (e.g. 2.0 for double points), and eligible_user_tiers. Modify the earn logic to check active campaigns and multiply the point award. Show active campaigns as an alert banner on the rewards page.
Referral points integration
Add a referral_code column to profiles. When a new user signs up using a referral code, insert earn transactions for both the referrer (500 points) and the new user (100 welcome points). Use a Postgres trigger on auth.users INSERT to handle this automatically.
Physical reward fulfillment
Add a shipping_address form to the redemption confirmation Dialog for physical rewards. Store the address in the redemptions table. Build an admin fulfillment queue that shows pending redemptions with addresses and a 'Mark Fulfilled' button that updates status and sends a confirmation email via an Edge Function.
Tier-exclusive rewards
Add a min_tier column to rewards. Filter the catalog to only show rewards accessible to the customer's current tier. Blur or lock inaccessible rewards with a tooltip showing what tier is required, creating a visible aspiration for customers to earn more points.
Common pitfalls
Pitfall: Storing a mutable points balance instead of using a ledger
How to avoid: Keep point_transactions as an append-only ledger. Always calculate the balance as SUM(points) WHERE user_id = ?. The trigger can cache lifetime_points in profiles for fast tier calculation, but the authoritative balance always comes from the sum.
Pitfall: Skipping SELECT FOR UPDATE on the rewards row during redemption
How to avoid: The atomic_redeem_reward Postgres function must use SELECT ... FOR UPDATE on the reward row. This serializes concurrent redemptions for the same reward. All other approaches (application-level locks, checking before inserting) have race conditions.
Pitfall: Calling the redemption Edge Function directly from client-side with the anon key
How to avoid: In the Edge Function, extract user_id from the Authorization JWT using supabase.auth.getUser(token) rather than trusting the request body. This prevents one user from redeeming rewards on behalf of another by forging the user_id parameter.
Pitfall: Not indexing point_transactions for balance queries
How to avoid: Add CREATE INDEX idx_point_tx_user ON point_transactions(user_id). For the tier trigger SUM, add the partial index idx_point_tx_earn ON point_transactions(user_id) WHERE type = 'earn' as described in Step 1.
Best practices
- Keep the point ledger append-only. Never UPDATE or DELETE point_transactions rows. If you need to reverse a transaction, insert a compensating entry with type='correction' and a negative points value.
- Validate the customer's identity server-side in the redemption Edge Function by verifying the JWT — never trust a user_id sent in the request body.
- Use SECURITY DEFINER on the atomic_redeem_reward Postgres function so it can bypass RLS when performing the cross-table atomic writes. Keep the function logic minimal to limit the security surface.
- Cache the tier and lifetime_points in the profiles table via the trigger, but treat them as derived data. The canonical source of truth is always the point_transactions ledger.
- Add database constraints to enforce data integrity: CHECK (points > 0) on earn rows, CHECK (points < 0) on redeem rows, CHECK (stock >= 0) on rewards. Let the database reject bad data before it reaches your application.
- Show customers their tier progress proactively. A progress bar to the next tier is one of the most effective engagement tools in loyalty programs. Calculate it from (lifetime_points - current_tier_floor) / (next_tier_floor - current_tier_floor).
- Log every redemption attempt in an audit table (success and failure) with the reason. This is invaluable for debugging support tickets and detecting abuse patterns.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a loyalty program in Supabase using an append-only point_transactions ledger. I need a Postgres function atomic_redeem_reward(p_reward_id uuid, p_user_id uuid) that runs inside a transaction, uses SELECT FOR UPDATE to lock the reward row, checks the customer's balance via SUM, decrements stock, and inserts a negative points transaction — all atomically. Show me the full plpgsql function including EXCEPTION handling for insufficient balance and out-of-stock scenarios.
Add a 'Points History' export feature to the loyalty dashboard. Add an 'Export CSV' Button below the transactions DataTable. When clicked, query all point_transactions for the current user ordered by created_at DESC, format them as CSV rows (date, type, points, description, running_balance), and trigger a browser download using a Blob URL. Calculate running_balance as a cumulative sum in JavaScript as you build the CSV rows.
In Supabase, create a pg_cron scheduled job that runs every Sunday at midnight. It should find all customers who have not made a purchase (no earn transactions) in the last 90 days and insert a notification row into a user_notifications table with type='points_expiring', message='Your X points will expire in 30 days if you don't earn new points', and send_at = now(). This drives a weekly re-engagement email flow.
Frequently asked questions
What prevents a customer from earning the same points twice?
The reference_id column on point_transactions stores the order or event ID that triggered the earn. Before inserting a new earn transaction, check that no row with the same reference_id already exists for that user. Add a UNIQUE constraint on (user_id, reference_id) to enforce this at the database level.
How do I integrate point earning with an actual purchase flow?
Create an Edge Function earn-points that accepts an order_id and user_id. It looks up the order total, calculates points (e.g. 1 point per dollar), and inserts a point_transactions row with type='earn' and reference_id=order_id. Call this function from your Stripe webhook handler after a successful payment_intent.succeeded event.
Can the tier trigger cause performance problems at scale?
The trigger fires on every earn INSERT and runs a SUM query. With the partial index on (user_id) WHERE type='earn', this query is fast even with thousands of transactions per user. For very high-volume systems (millions of transactions), consider a materialized column approach where you UPDATE lifetime_points incrementally instead of recalculating the full sum each time.
How do I handle partial redemptions or refunds?
Insert a correcting earn transaction rather than deleting the redeem transaction. If a customer returns an item they used 200 points to discount, insert a new row with type='earn', points=200, description='Refund: Order #1234'. This maintains the append-only ledger. Update the redemptions row status to 'cancelled'.
What happens if the Edge Function fails halfway through a redemption?
The atomic_redeem_reward Postgres function runs inside a single database transaction. If any step fails — balance check, stock decrement, transaction insert — the entire transaction rolls back. The customer's balance and the reward's stock remain unchanged. No partial state is possible.
Can I add multiple tiers beyond Bronze, Silver, Gold?
Yes. Modify the CASE statement in the tier trigger to add Platinum (5000+), Diamond (10000+), or any tiers you want. The profiles.tier column is a text field, so no migration is needed for the column itself. Update the frontend Badge colors and tier progress calculation to reflect the new thresholds.
How do I show the loyalty widget on a separate marketing site?
Create a public API Edge Function get-loyalty-status that accepts a user token and returns their tier, balance, and progress to next tier as JSON. Your marketing site can call this endpoint with the customer's Supabase JWT to display a loyalty widget. Enable CORS in the Edge Function to allow cross-origin requests.
Is there help available to build a more complex loyalty or rewards system?
RapidDev builds production-ready Lovable apps including loyalty systems with Stripe integration, email automation, and custom tier logic. Reach out if your requirements go beyond this guide.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation