Build a points-based loyalty and rewards system with V0 using Next.js, Supabase, Stripe, and shadcn/ui. Members earn points on purchases, advance through tiered status levels, and redeem rewards — with atomic point transactions via Supabase RPC to prevent negative balances. Takes about 1-2 hours.
What you're building
Loyalty programs increase customer retention by 20-30%, but commercial solutions charge per-member fees that scale painfully. A custom-built loyalty system gives you full control over earning rules, tiers, and rewards without recurring platform costs.
V0 generates the member dashboard, reward catalog, and Stripe webhook handler from prompts. Connect Supabase via the Connect panel for the database and add Stripe via the Vercel Marketplace for automatic key provisioning.
The architecture uses a Stripe webhook to trigger point earning on purchases, Supabase RPC functions for atomic redemptions, tiered status based on lifetime points, and Server Components for the dashboard with client components for interactive elements like the redemption confirmation dialog.
Final result
A complete loyalty program with points earning via Stripe, tiered member status, a reward catalog with redemption, and a full transaction history.
Tech stack
Prerequisites
- A V0 account (Premium or higher recommended)
- A Supabase project (free tier works — connect via V0's Connect panel)
- A Stripe account (test mode works — connect via Vercel Marketplace)
- Your reward catalog and tier structure defined
Build steps
Set up the database schema for members, rewards, and tiers
Create the Supabase schema with tables for members, point transactions, rewards, redemptions, and tier rules. The tier_rules table defines the points threshold and multiplier for each status level.
1// Paste this prompt into V0's AI chat:2// Build a loyalty program system. Create a Supabase schema:3// 1. members: id (uuid PK), user_id (uuid FK to auth.users), points_balance (int DEFAULT 0), lifetime_points (int DEFAULT 0), tier (text DEFAULT 'bronze'), joined_at (timestamptz)4// 2. point_transactions: id (uuid PK), member_id (uuid FK to members), points (int), type (text CHECK IN 'earn','redeem','expire','adjust'), description (text), reference_id (text), created_at (timestamptz)5// 3. rewards: id (uuid PK), name (text), description (text), points_cost (int), stock (int), image_url (text), is_active (boolean DEFAULT true)6// 4. redemptions: id (uuid PK), member_id (uuid FK to members), reward_id (uuid FK to rewards), points_spent (int), status (text DEFAULT 'pending'), created_at (timestamptz)7// 5. tier_rules: tier (text PK), min_lifetime_points (int), multiplier (numeric DEFAULT 1.0), perks (text[])8// Seed tier_rules: bronze (0, 1x), silver (1000, 1.25x), gold (5000, 1.5x), platinum (15000, 2x).9// Add RLS policies. Generate SQL and TypeScript types.Pro tip: Use V0's Connect panel to link Supabase and auto-configure the anon key, then use the Git panel to push to GitHub for team review before publishing.
Expected result: Supabase is connected with all tables created and tier rules seeded. RLS policies restrict members to their own data.
Build the member dashboard with tier progress
Create the loyalty dashboard showing points balance, current tier with badge, progress to next tier, and available rewards. This page uses Server Components for instant load.
1// Paste this prompt into V0's AI chat:2// Create a loyalty dashboard at app/loyalty/page.tsx.3// Requirements:4// - Fetch the current user's member record with tier_rules joined5// - Show a hero Card with: points_balance in large text, tier Badge (bronze=brown, silver=gray, gold=amber, platinum=purple), member since date6// - Show Progress bar toward next tier with text: 'X more points to [next tier]'7// - Show tier perks list from tier_rules.perks8// - Below, show a grid of reward Cards: image, name, points_cost, stock remaining, 'Redeem' Button9// - Rewards with insufficient points show the Button as disabled with 'Need X more points'10// - Rewards with stock = 0 show 'Sold Out' Badge11// - Add Tabs for 'Rewards' and 'History'12// - History tab shows a Table of point_transactions: date, type Badge (earn=green, redeem=red, expire=gray), points, descriptionExpected result: The dashboard shows points balance, tier progress, reward catalog, and transaction history in a clean tabbed layout.
Create the atomic redemption Server Action
Build the redemption logic using a Supabase RPC function that atomically checks balance, deducts points, and creates the redemption record in a single transaction. This prevents negative balances even under concurrent requests.
1-- Run this in Supabase SQL Editor2CREATE OR REPLACE FUNCTION redeem_reward(3 p_member_id uuid,4 p_reward_id uuid5)6RETURNS json AS $$7DECLARE8 v_points_cost int;9 v_balance int;10 v_stock int;11 v_redemption_id uuid;12BEGIN13 -- Lock and fetch reward14 SELECT points_cost, stock INTO v_points_cost, v_stock15 FROM rewards WHERE id = p_reward_id AND is_active = true16 FOR UPDATE;1718 IF NOT FOUND THEN19 RAISE EXCEPTION 'Reward not found or inactive';20 END IF;2122 IF v_stock <= 0 THEN23 RAISE EXCEPTION 'Reward out of stock';24 END IF;2526 -- Lock and fetch member balance27 SELECT points_balance INTO v_balance28 FROM members WHERE id = p_member_id29 FOR UPDATE;3031 IF v_balance < v_points_cost THEN32 RAISE EXCEPTION 'Insufficient points';33 END IF;3435 -- Deduct points36 UPDATE members SET points_balance = points_balance - v_points_cost WHERE id = p_member_id;3738 -- Decrement stock39 UPDATE rewards SET stock = stock - 1 WHERE id = p_reward_id;4041 -- Create redemption42 INSERT INTO redemptions (member_id, reward_id, points_spent)43 VALUES (p_member_id, p_reward_id, v_points_cost)44 RETURNING id INTO v_redemption_id;4546 -- Log transaction47 INSERT INTO point_transactions (member_id, points, type, description, reference_id)48 VALUES (p_member_id, -v_points_cost, 'redeem', 'Reward redemption', v_redemption_id::text);4950 RETURN json_build_object('redemption_id', v_redemption_id, 'new_balance', v_balance - v_points_cost);51END;52$$ LANGUAGE plpgsql SECURITY DEFINER;Pro tip: The FOR UPDATE clause locks the rows during the transaction, preventing two concurrent redemptions from spending the same points.
Expected result: The RPC function atomically validates balance, deducts points, decrements stock, and creates the redemption. If anything fails, the entire transaction rolls back.
Set up Stripe webhook for point earning
Create the webhook handler that listens for payment_intent.succeeded events from Stripe and awards loyalty points based on the purchase amount and the member's tier multiplier.
1import { NextRequest, NextResponse } from 'next/server'2import Stripe from 'stripe'3import { createClient } from '@supabase/supabase-js'45const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)6const supabase = createClient(7 process.env.SUPABASE_URL!,8 process.env.SUPABASE_SERVICE_ROLE_KEY!9)1011export async function POST(req: NextRequest) {12 const rawBody = await req.text()13 const sig = req.headers.get('stripe-signature')!1415 let event: Stripe.Event16 try {17 event = stripe.webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET!)18 } catch {19 return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })20 }2122 if (event.type === 'payment_intent.succeeded') {23 const intent = event.data.object as Stripe.PaymentIntent24 const userId = intent.metadata?.user_id25 if (!userId) return NextResponse.json({ received: true })2627 const { data: member } = await supabase28 .from('members')29 .select('id, tier')30 .eq('user_id', userId)31 .single()3233 if (!member) return NextResponse.json({ received: true })3435 const { data: tierRule } = await supabase36 .from('tier_rules')37 .select('multiplier')38 .eq('tier', member.tier)39 .single()4041 const basePoints = Math.floor(intent.amount / 100)42 const earnedPoints = Math.floor(basePoints * (tierRule?.multiplier || 1))4344 await supabase.rpc('earn_points', {45 p_member_id: member.id,46 p_points: earnedPoints,47 p_description: `Purchase: $${(intent.amount / 100).toFixed(2)}`,48 p_reference: intent.id,49 })50 }5152 return NextResponse.json({ received: true })53}Expected result: After a successful Stripe payment, the webhook awards points based on the purchase amount multiplied by the member's tier multiplier.
Add the reward catalog and redemption UI, then deploy
Build the rewards browsing page with redemption confirmation dialogs and deploy the loyalty program to production.
1// Paste this prompt into V0's AI chat:2// Create a reward catalog at app/loyalty/rewards/page.tsx.3// Requirements:4// - Fetch all active rewards from Supabase5// - Display as a Card grid: reward image, name, description, points_cost in large text, stock Badge6// - 'Redeem' Button on each Card. If member points < points_cost, disable and show 'Need X more points'7// - Clicking Redeem opens an AlertDialog: 'Are you sure? This will deduct [points_cost] points from your balance.'8// - On confirm, call a Server Action that invokes the redeem_reward RPC function9// - Show a success Toast with 'Reward redeemed! Your new balance is X points'10// - If error (insufficient points or out of stock), show an error Toast11// - Add search Input and category filter Select above the grid12// - Show the member's current balance in a sticky header Card for referenceExpected result: The reward catalog shows available rewards with redemption dialogs. Points are deducted atomically and balances update in real-time.
Complete code
1import { NextRequest, NextResponse } from 'next/server'2import Stripe from 'stripe'3import { createClient } from '@supabase/supabase-js'45const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)6const supabase = createClient(7 process.env.SUPABASE_URL!,8 process.env.SUPABASE_SERVICE_ROLE_KEY!9)1011export async function POST(req: NextRequest) {12 const rawBody = await req.text()13 const sig = req.headers.get('stripe-signature')!1415 let event: Stripe.Event16 try {17 event = stripe.webhooks.constructEvent(18 rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET!19 )20 } catch {21 return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })22 }2324 if (event.type === 'payment_intent.succeeded') {25 const intent = event.data.object as Stripe.PaymentIntent26 const userId = intent.metadata?.user_id27 if (!userId) return NextResponse.json({ received: true })2829 const { data: member } = await supabase30 .from('members')31 .select('id, tier')32 .eq('user_id', userId)33 .single()3435 if (!member) return NextResponse.json({ received: true })3637 const { data: rule } = await supabase38 .from('tier_rules')39 .select('multiplier')40 .eq('tier', member.tier)41 .single()4243 const points = Math.floor(44 (intent.amount / 100) * (rule?.multiplier || 1)45 )4647 await supabase.rpc('earn_points', {48 p_member_id: member.id,49 p_points: points,50 p_description: `Purchase $${(intent.amount / 100).toFixed(2)}`,51 p_reference: intent.id,52 })53 }5455 return NextResponse.json({ received: true })56}Customization ideas
Birthday bonus points
Store member birthdays and use a Vercel Cron Job to award bonus points on their birthday, triggered by a daily check.
Referral rewards
Generate unique referral codes per member and award points when a referred user makes their first purchase.
Point expiration policy
Add a Vercel Cron Job that marks points older than 12 months as expired by inserting 'expire' transactions and updating balances.
Digital reward cards
Generate QR codes for redeemed rewards using the qrcode library, displayed in a mobile-friendly Card for in-store scanning.
Common pitfalls
Pitfall: Deducting points with a simple UPDATE instead of an atomic transaction
How to avoid: Use a Supabase RPC function with FOR UPDATE row locking that checks balance, deducts, and creates the redemption in a single transaction.
Pitfall: Using request.json() in the Stripe webhook handler
How to avoid: Use request.text() to get the raw body, then pass it to stripe.webhooks.constructEvent(rawBody, sig, secret).
Pitfall: Calculating tier multipliers on the client side
How to avoid: Compute point amounts with tier multipliers in the Stripe webhook handler (server-side). The client only sees the final points awarded.
Best practices
- Use Supabase RPC functions with FOR UPDATE row locking for all point-changing operations to prevent race conditions
- Store tier rules in a database table rather than hardcoding — this lets you adjust thresholds and multipliers without deploying
- Always use request.text() for Stripe webhook raw body verification, never request.json()
- Track both points_balance (spendable) and lifetime_points (for tier calculation) separately — redemptions reduce balance but not lifetime
- Use V0's Design Mode (Option+D) to adjust tier Badge colors and reward Card layouts without spending credits
- Set STRIPE_WEBHOOK_SECRET in the Vars tab without NEXT_PUBLIC_ prefix — it is server-only
- Use Server Components for the dashboard and reward catalog for fast initial load
- Log every point change as a transaction for a complete audit trail — never update balances without a corresponding transaction record
AI prompts to try
Copy these prompts to build this project faster.
I'm building a loyalty program with Next.js App Router, Supabase, and Stripe. I need a PostgreSQL function called redeem_reward that takes member_id and reward_id, atomically checks the member's points balance against the reward cost, deducts points, decrements reward stock, creates a redemption record, and logs a point transaction. Use FOR UPDATE row locks to prevent concurrent redemption issues. If balance is insufficient or stock is 0, raise an exception.
Create a tier advancement system. When points are earned, check if the member's lifetime_points now exceeds the next tier's min_lifetime_points in the tier_rules table. If so, update the member's tier and show a celebration dialog congratulating them on their new status. Include the tier's perks list and new multiplier in the celebration message.
Frequently asked questions
How are points earned from purchases?
A Stripe webhook listens for payment_intent.succeeded events. When a payment completes, the webhook calculates points as (purchase amount in dollars) times the member's tier multiplier. For example, a $50 purchase by a Gold member (1.5x) earns 75 points.
How does the tier system work?
Tiers are based on lifetime_points (total points ever earned, not current balance). The tier_rules table defines thresholds: Bronze (0), Silver (1000), Gold (5000), Platinum (15000). Each tier has a points multiplier so higher tiers earn faster.
What prevents two people from redeeming the same reward simultaneously?
The redeem_reward Supabase RPC function uses FOR UPDATE row locking. It locks both the member and reward rows during the transaction, ensuring that only one redemption completes at a time. If balance is insufficient or stock hits zero, the entire transaction rolls back.
Do I need a paid V0 plan?
Premium ($20/month) is recommended. The loyalty program has multiple pages and a Stripe webhook handler that require several prompts to build.
Can I add point expiration?
Yes. Set up a Vercel Cron Job that runs monthly, queries points older than your expiration period (e.g., 12 months), inserts 'expire' transactions, and updates member balances accordingly.
How do I deploy the loyalty program?
Click Share in V0, then Publish to Production. Set STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and Supabase keys in the Vars tab. After deploying, register your webhook URL in the Stripe Dashboard for the payment_intent.succeeded event.
Can RapidDev help build a custom loyalty program?
Yes. RapidDev has built over 600 apps including loyalty and rewards platforms with custom tier logic, referral programs, and POS integrations. Book a free consultation to discuss your loyalty strategy.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation