Skip to main content
RapidDev - Software Development Agency

How to Build Loyalty program with V0

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

  • Member dashboard with points balance, tier Badge (bronze/silver/gold/platinum), and available rewards
  • Points earning triggered by Stripe webhook on payment_intent.succeeded events
  • Atomic point redemption via Supabase RPC preventing negative balances under concurrent requests
  • Tier advancement system with Progress bars and configurable tier rules with point multipliers
  • Reward catalog with Card-based layout, stock tracking, and redemption confirmation AlertDialog
  • Transaction history Table showing all point earn, redeem, expire, and adjustment events
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate10 min read1-2 hoursV0 Premium or higherApril 2026RapidDev Engineering Team
TL;DR

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

V0AI Code Generator
Next.jsFull-Stack Framework
Tailwind CSSStyling
shadcn/uiComponent Library
SupabaseDatabase
StripePayments

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

1

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.

prompt.txt
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.

2

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.

prompt.txt
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 joined
5// - Show a hero Card with: points_balance in large text, tier Badge (bronze=brown, silver=gray, gold=amber, platinum=purple), member since date
6// - Show Progress bar toward next tier with text: 'X more points to [next tier]'
7// - Show tier perks list from tier_rules.perks
8// - Below, show a grid of reward Cards: image, name, points_cost, stock remaining, 'Redeem' Button
9// - Rewards with insufficient points show the Button as disabled with 'Need X more points'
10// - Rewards with stock = 0 show 'Sold Out' Badge
11// - 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, description

Expected result: The dashboard shows points balance, tier progress, reward catalog, and transaction history in a clean tabbed layout.

3

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.

supabase/migrations/redeem_reward.sql
1-- Run this in Supabase SQL Editor
2CREATE OR REPLACE FUNCTION redeem_reward(
3 p_member_id uuid,
4 p_reward_id uuid
5)
6RETURNS json AS $$
7DECLARE
8 v_points_cost int;
9 v_balance int;
10 v_stock int;
11 v_redemption_id uuid;
12BEGIN
13 -- Lock and fetch reward
14 SELECT points_cost, stock INTO v_points_cost, v_stock
15 FROM rewards WHERE id = p_reward_id AND is_active = true
16 FOR UPDATE;
17
18 IF NOT FOUND THEN
19 RAISE EXCEPTION 'Reward not found or inactive';
20 END IF;
21
22 IF v_stock <= 0 THEN
23 RAISE EXCEPTION 'Reward out of stock';
24 END IF;
25
26 -- Lock and fetch member balance
27 SELECT points_balance INTO v_balance
28 FROM members WHERE id = p_member_id
29 FOR UPDATE;
30
31 IF v_balance < v_points_cost THEN
32 RAISE EXCEPTION 'Insufficient points';
33 END IF;
34
35 -- Deduct points
36 UPDATE members SET points_balance = points_balance - v_points_cost WHERE id = p_member_id;
37
38 -- Decrement stock
39 UPDATE rewards SET stock = stock - 1 WHERE id = p_reward_id;
40
41 -- Create redemption
42 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;
45
46 -- Log transaction
47 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);
49
50 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.

4

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.

app/api/webhooks/stripe/route.ts
1import { NextRequest, NextResponse } from 'next/server'
2import Stripe from 'stripe'
3import { createClient } from '@supabase/supabase-js'
4
5const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
6const supabase = createClient(
7 process.env.SUPABASE_URL!,
8 process.env.SUPABASE_SERVICE_ROLE_KEY!
9)
10
11export async function POST(req: NextRequest) {
12 const rawBody = await req.text()
13 const sig = req.headers.get('stripe-signature')!
14
15 let event: Stripe.Event
16 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 }
21
22 if (event.type === 'payment_intent.succeeded') {
23 const intent = event.data.object as Stripe.PaymentIntent
24 const userId = intent.metadata?.user_id
25 if (!userId) return NextResponse.json({ received: true })
26
27 const { data: member } = await supabase
28 .from('members')
29 .select('id, tier')
30 .eq('user_id', userId)
31 .single()
32
33 if (!member) return NextResponse.json({ received: true })
34
35 const { data: tierRule } = await supabase
36 .from('tier_rules')
37 .select('multiplier')
38 .eq('tier', member.tier)
39 .single()
40
41 const basePoints = Math.floor(intent.amount / 100)
42 const earnedPoints = Math.floor(basePoints * (tierRule?.multiplier || 1))
43
44 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 }
51
52 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.

5

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.

prompt.txt
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 Supabase
5// - Display as a Card grid: reward image, name, description, points_cost in large text, stock Badge
6// - '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 function
9// - 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 Toast
11// - Add search Input and category filter Select above the grid
12// - Show the member's current balance in a sticky header Card for reference

Expected result: The reward catalog shows available rewards with redemption dialogs. Points are deducted atomically and balances update in real-time.

Complete code

app/api/webhooks/stripe/route.ts
1import { NextRequest, NextResponse } from 'next/server'
2import Stripe from 'stripe'
3import { createClient } from '@supabase/supabase-js'
4
5const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
6const supabase = createClient(
7 process.env.SUPABASE_URL!,
8 process.env.SUPABASE_SERVICE_ROLE_KEY!
9)
10
11export async function POST(req: NextRequest) {
12 const rawBody = await req.text()
13 const sig = req.headers.get('stripe-signature')!
14
15 let event: Stripe.Event
16 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 }
23
24 if (event.type === 'payment_intent.succeeded') {
25 const intent = event.data.object as Stripe.PaymentIntent
26 const userId = intent.metadata?.user_id
27 if (!userId) return NextResponse.json({ received: true })
28
29 const { data: member } = await supabase
30 .from('members')
31 .select('id, tier')
32 .eq('user_id', userId)
33 .single()
34
35 if (!member) return NextResponse.json({ received: true })
36
37 const { data: rule } = await supabase
38 .from('tier_rules')
39 .select('multiplier')
40 .eq('tier', member.tier)
41 .single()
42
43 const points = Math.floor(
44 (intent.amount / 100) * (rule?.multiplier || 1)
45 )
46
47 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 }
54
55 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.

ChatGPT Prompt

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.

Build Prompt

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.

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.