Skip to main content
RapidDev - Software Development Agency

How to Build a Loyalty Program with Lovable

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'll build

  • Point transaction ledger table with earn and redeem entries and a running balance
  • Tier auto-calculation Postgres trigger that upgrades customers from Bronze to Silver to Gold based on lifetime points
  • Atomic redemption Edge Function using SELECT FOR UPDATE to prevent double-spend
  • Reward catalog with Card grid showing point cost, availability, and redemption button
  • Customer dashboard with current balance, tier Badge, and transaction history DataTable
  • Tier progress Bar showing points to next tier
  • Admin page for adding rewards and viewing all customer point balances
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate15 min read2.5–3 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
TL;DR

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

LovableFrontend dashboard and admin UI
SupabaseDatabase with RLS
Supabase Edge FunctionsAtomic redemption logic (Deno)
shadcn/uiUI components
RechartsPoints earned over time chart
Supabase AuthCustomer and admin authentication

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

1

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.

prompt.txt
1Create a loyalty program database schema in Supabase.
2
3Tables:
4- profiles: extend with columns tier (text default 'Bronze'), lifetime_points (int default 0), updated_at
5- rewards: id, name, description, point_cost (int), stock (int), image_url, is_active (bool default true), created_at
6- 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_at
7- redemptions: id, user_id, reward_id (references rewards), points_spent (int), status ('pending' | 'fulfilled' | 'cancelled'), created_at
8
9Create 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_pts
112. 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_id
12
13RLS policies:
14- point_transactions: users SELECT/INSERT their own rows (user_id = auth.uid())
15- rewards: public SELECT, admin-only INSERT/UPDATE/DELETE
16- redemptions: users SELECT their own, service role INSERT
17- profiles: users SELECT/UPDATE their own row

Pro 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.

2

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.

supabase/functions/redeem-reward/index.ts
1// supabase/functions/redeem-reward/index.ts
2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
4
5const corsHeaders = {
6 'Access-Control-Allow-Origin': '*',
7 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
8 'Content-Type': 'application/json',
9}
10
11serve(async (req: Request) => {
12 if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
13
14 try {
15 const supabase = createClient(
16 Deno.env.get('SUPABASE_URL') ?? '',
17 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
18 )
19
20 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 }
24
25 // 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 })
30
31 if (error) {
32 return new Response(JSON.stringify({ error: error.message }), { status: 400, headers: corsHeaders })
33 }
34
35 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.

3

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.

prompt.txt
1Build a customer loyalty dashboard at src/pages/Dashboard.tsx.
2
3Layout:
4- Top row: three stat Cards side by side
5 - Card 1: 'Your Points' show current balance (SUM of all transactions for auth user), large number
6 - Card 2: 'Your Tier' show tier as a colored Badge (Bronze=amber, Silver=gray, Gold=yellow) with a Crown icon for Gold
7 - Card 3: 'Lifetime Earned' show total earn-type points
8- 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'.
11
12All 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.

4

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.

prompt.txt
1Build a rewards catalog page at src/pages/Rewards.tsx.
2
3Requirements:
4- Fetch all rewards WHERE is_active = true, ordered by point_cost ASC
5- 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' Button
7- 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 buttons
9- 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 completion
11- After successful redemption, invalidate and refetch the customer's point balance

Expected 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.

5

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.

prompt.txt
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).
2
3Two Tabs: 'Rewards' and 'Customers'.
4
5Rewards tab:
6- DataTable of all rewards with columns: Name, Point Cost, Stock, Active Toggle (Switch component), Edit Button
7- '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 updating
9
10Customers tab:
11- DataTable fetching profiles joined with point balance SUM. Columns: Email (from auth.users), Tier Badge, Balance, Lifetime Points, Joined Date
12- 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

supabase/functions/redeem-reward/index.ts
1import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
2import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
3
4const corsHeaders = {
5 'Access-Control-Allow-Origin': '*',
6 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
7 'Content-Type': 'application/json',
8}
9
10serve(async (req: Request) => {
11 if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
12
13 const supabase = createClient(
14 Deno.env.get('SUPABASE_URL') ?? '',
15 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
16 )
17
18 try {
19 const { reward_id, user_id } = await req.json()
20
21 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 }
27
28 // Atomic redemption: lock reward row, check balance, decrement stock, insert transaction
29 const { data, error } = await supabase.rpc('atomic_redeem_reward', {
30 p_reward_id: reward_id,
31 p_user_id: user_id,
32 })
33
34 if (error) {
35 const status = error.message.includes('Insufficient') || error.message.includes('stock') ? 400 : 500
36 return new Response(JSON.stringify({ error: error.message }), { status, headers: corsHeaders })
37 }
38
39 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})
48
49/*
50Postgres function for Lovable to create:
51
52CREATE OR REPLACE FUNCTION atomic_redeem_reward(
53 p_reward_id uuid,
54 p_user_id uuid
55) RETURNS uuid
56LANGUAGE plpgsql
57SECURITY DEFINER
58AS $$
59DECLARE
60 v_reward rewards%ROWTYPE;
61 v_balance int;
62 v_redemption_id uuid;
63BEGIN
64 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_balance
68 FROM point_transactions WHERE user_id = p_user_id;
69 IF v_balance < v_reward.point_cost THEN
70 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.

ChatGPT Prompt

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.

Lovable Prompt

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.

Build Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.