# How to Build Loyalty program with V0

- Tool: v0
- Difficulty: Intermediate
- Compatibility: V0 Premium or higher
- Last updated: April 2026

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

## Before you start

- 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

## Step-by-step guide

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

```
// Paste this prompt into V0's AI chat:
// Build a loyalty program system. Create a Supabase schema:
// 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)
// 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)
// 3. rewards: id (uuid PK), name (text), description (text), points_cost (int), stock (int), image_url (text), is_active (boolean DEFAULT true)
// 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)
// 5. tier_rules: tier (text PK), min_lifetime_points (int), multiplier (numeric DEFAULT 1.0), perks (text[])
// Seed tier_rules: bronze (0, 1x), silver (1000, 1.25x), gold (5000, 1.5x), platinum (15000, 2x).
// 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.

```
// Paste this prompt into V0's AI chat:
// Create a loyalty dashboard at app/loyalty/page.tsx.
// Requirements:
// - Fetch the current user's member record with tier_rules joined
// - Show a hero Card with: points_balance in large text, tier Badge (bronze=brown, silver=gray, gold=amber, platinum=purple), member since date
// - Show Progress bar toward next tier with text: 'X more points to [next tier]'
// - Show tier perks list from tier_rules.perks
// - Below, show a grid of reward Cards: image, name, points_cost, stock remaining, 'Redeem' Button
// - Rewards with insufficient points show the Button as disabled with 'Need X more points'
// - Rewards with stock = 0 show 'Sold Out' Badge
// - Add Tabs for 'Rewards' and 'History'
// - 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.

```
-- Run this in Supabase SQL Editor
CREATE OR REPLACE FUNCTION redeem_reward(
  p_member_id uuid,
  p_reward_id uuid
)
RETURNS json AS $$
DECLARE
  v_points_cost int;
  v_balance int;
  v_stock int;
  v_redemption_id uuid;
BEGIN
  -- Lock and fetch reward
  SELECT points_cost, stock INTO v_points_cost, v_stock
  FROM rewards WHERE id = p_reward_id AND is_active = true
  FOR UPDATE;

  IF NOT FOUND THEN
    RAISE EXCEPTION 'Reward not found or inactive';
  END IF;

  IF v_stock <= 0 THEN
    RAISE EXCEPTION 'Reward out of stock';
  END IF;

  -- Lock and fetch member balance
  SELECT points_balance INTO v_balance
  FROM members WHERE id = p_member_id
  FOR UPDATE;

  IF v_balance < v_points_cost THEN
    RAISE EXCEPTION 'Insufficient points';
  END IF;

  -- Deduct points
  UPDATE members SET points_balance = points_balance - v_points_cost WHERE id = p_member_id;

  -- Decrement stock
  UPDATE rewards SET stock = stock - 1 WHERE id = p_reward_id;

  -- Create redemption
  INSERT INTO redemptions (member_id, reward_id, points_spent)
  VALUES (p_member_id, p_reward_id, v_points_cost)
  RETURNING id INTO v_redemption_id;

  -- Log transaction
  INSERT INTO point_transactions (member_id, points, type, description, reference_id)
  VALUES (p_member_id, -v_points_cost, 'redeem', 'Reward redemption', v_redemption_id::text);

  RETURN json_build_object('redemption_id', v_redemption_id, 'new_balance', v_balance - v_points_cost);
END;
$$ 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.

```
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { createClient } from '@supabase/supabase-js'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const rawBody = await req.text()
  const sig = req.headers.get('stripe-signature')!

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET!)
  } catch {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }

  if (event.type === 'payment_intent.succeeded') {
    const intent = event.data.object as Stripe.PaymentIntent
    const userId = intent.metadata?.user_id
    if (!userId) return NextResponse.json({ received: true })

    const { data: member } = await supabase
      .from('members')
      .select('id, tier')
      .eq('user_id', userId)
      .single()

    if (!member) return NextResponse.json({ received: true })

    const { data: tierRule } = await supabase
      .from('tier_rules')
      .select('multiplier')
      .eq('tier', member.tier)
      .single()

    const basePoints = Math.floor(intent.amount / 100)
    const earnedPoints = Math.floor(basePoints * (tierRule?.multiplier || 1))

    await supabase.rpc('earn_points', {
      p_member_id: member.id,
      p_points: earnedPoints,
      p_description: `Purchase: $${(intent.amount / 100).toFixed(2)}`,
      p_reference: intent.id,
    })
  }

  return NextResponse.json({ received: true })
}
```

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

```
// Paste this prompt into V0's AI chat:
// Create a reward catalog at app/loyalty/rewards/page.tsx.
// Requirements:
// - Fetch all active rewards from Supabase
// - Display as a Card grid: reward image, name, description, points_cost in large text, stock Badge
// - 'Redeem' Button on each Card. If member points < points_cost, disable and show 'Need X more points'
// - Clicking Redeem opens an AlertDialog: 'Are you sure? This will deduct [points_cost] points from your balance.'
// - On confirm, call a Server Action that invokes the redeem_reward RPC function
// - Show a success Toast with 'Reward redeemed! Your new balance is X points'
// - If error (insufficient points or out of stock), show an error Toast
// - Add search Input and category filter Select above the grid
// - 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 example

File: `app/api/webhooks/stripe/route.ts`

```typescript
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { createClient } from '@supabase/supabase-js'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const rawBody = await req.text()
  const sig = req.headers.get('stripe-signature')!

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(
      rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }

  if (event.type === 'payment_intent.succeeded') {
    const intent = event.data.object as Stripe.PaymentIntent
    const userId = intent.metadata?.user_id
    if (!userId) return NextResponse.json({ received: true })

    const { data: member } = await supabase
      .from('members')
      .select('id, tier')
      .eq('user_id', userId)
      .single()

    if (!member) return NextResponse.json({ received: true })

    const { data: rule } = await supabase
      .from('tier_rules')
      .select('multiplier')
      .eq('tier', member.tier)
      .single()

    const points = Math.floor(
      (intent.amount / 100) * (rule?.multiplier || 1)
    )

    await supabase.rpc('earn_points', {
      p_member_id: member.id,
      p_points: points,
      p_description: `Purchase $${(intent.amount / 100).toFixed(2)}`,
      p_reference: intent.id,
    })
  }

  return NextResponse.json({ received: true })
}
```

## Common mistakes

- **Deducting points with a simple UPDATE instead of an atomic transaction** — Two concurrent redemptions can both read the same balance and both succeed, spending more points than available and creating a negative balance. Fix: Use a Supabase RPC function with FOR UPDATE row locking that checks balance, deducts, and creates the redemption in a single transaction.
- **Using request.json() in the Stripe webhook handler** — Stripe signature verification requires the raw body. Parsing with request.json() then re-stringifying changes the format and breaks verification. Fix: Use request.text() to get the raw body, then pass it to stripe.webhooks.constructEvent(rawBody, sig, secret).
- **Calculating tier multipliers on the client side** — Client-side calculations can be manipulated by modifying JavaScript, and they add unnecessary bundle size. Fix: 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

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

---

Source: https://www.rapidevelopers.com/how-to-build-v0/loyalty-program
© RapidDev — https://www.rapidevelopers.com/how-to-build-v0/loyalty-program
