# How to Build a Personalization System with Lovable

- Tool: How to Build with Lovable
- Difficulty: Advanced
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

Build a behavioral personalization system in Lovable that tracks user_events, computes audience segments from rule builders, serves content variants per segment, and runs A/B tests — all backed by Supabase. A scheduled Edge Function recomputes segment membership nightly so your content always matches the right audience without manual tagging.

## Before you start

- Lovable Pro account for multi-function and Edge Function generation
- Supabase project with Edge Functions enabled (Pro plan recommended for pg_cron)
- Supabase service role key saved to Cloud tab → Secrets as SUPABASE_SERVICE_ROLE_KEY
- Basic understanding of product analytics concepts (events, funnels, segments)
- Optional: existing Lovable app with user auth to add personalization to

## Step-by-step guide

### 1. Set up the personalization database schema

Prompt Lovable to create all the tables that power event tracking, segment computation, content variants, and A/B testing. Getting the schema right before building the UI avoids costly rewrites later.

```
Create a behavioral personalization system schema in Supabase:

- user_events: id, user_id (nullable, for anonymous events), session_id, event_name (text), properties (jsonb), page_url (text), created_at
- segments: id, name, description, rules (jsonb array of condition groups), is_active (bool), recomputed_at, member_count (int, denormalized)
- user_segment_memberships: id, user_id, segment_id, assigned_at, expires_at (nullable). Unique constraint on (user_id, segment_id).
- content_slots: id, slot_key (text, unique, e.g. 'hero_heading'), description, default_content (jsonb)
- content_variants: id, slot_key, segment_id (nullable, null = default), content (jsonb), priority (int default 0)
- ab_tests: id, name, slot_key, traffic_split (jsonb, e.g. {"control": 50, "variant_a": 50}), status (draft|running|stopped|completed), started_at, ended_at
- ab_test_variants: id, test_id, variant_key (e.g. 'control', 'variant_a'), content (jsonb), impressions (int default 0), conversions (int default 0)
- user_ab_assignments: id, user_id, test_id, variant_key, assigned_at. Unique constraint on (user_id, test_id).

RLS:
- user_events: authenticated users can INSERT their own events (user_id = auth.uid()). Service role can SELECT all.
- user_segment_memberships: users can SELECT their own memberships. Service role can INSERT/UPDATE/DELETE.
- content_slots, content_variants, segments, ab_tests, ab_test_variants: public SELECT. Service/admin-only writes.
- user_ab_assignments: users can SELECT/INSERT their own assignments.

Create index: CREATE INDEX idx_events_user_name ON user_events(user_id, event_name, created_at DESC).
```

> Pro tip: Add a GIN index on user_events.properties to enable fast filtering by JSON properties: CREATE INDEX idx_events_properties ON user_events USING gin(properties). This makes segment rules that filter on event properties (e.g. plan='pro') much faster.

**Expected result:** All tables are created with correct constraints and RLS policies. Indexes are in place. The app shell renders in preview.

### 2. Build the segment rule builder UI

Create the segment editor page with a visual rule builder. Users can add condition groups (OR between groups, AND within a group) and choose from attribute types like event count, event property value, and user property. The rules are serialized as JSONB and saved to the segments table.

```
Build a Segment editor page at src/pages/SegmentEditor.tsx.

The rule structure as TypeScript types:
type Condition = { attribute: 'event_count' | 'event_property' | 'user_property', event?: string, property?: string, operator: 'eq' | 'gt' | 'lt' | 'contains' | 'in', value: string | number, timeframe_days?: number }
type ConditionGroup = { operator: 'AND', conditions: Condition[] }
type SegmentRules = { operator: 'OR', groups: ConditionGroup[] }

UI requirements:
- Page header with segment name Input and description Textarea
- Rule builder section shows condition groups as Cards
- Each group Card has a header 'Group N' and an 'Add condition' Button
- Each condition row has:
  - Attribute Select: 'Did event', 'Event property', 'User property'
  - When 'Did event': event name Input, operator Select (more than/less than/exactly), count Input, timeframe Select (last 7/30/90 days)
  - When 'Event property': event name Input, property name Input, operator Select (equals/contains/is in), value Input
  - A Remove button (X icon) for each condition
- Between groups show an 'OR' Badge
- 'Add Group' Button at the bottom adds a new empty ConditionGroup
- 'Save Segment' Button that serializes the form state to SegmentRules JSONB and upserts to the segments table
- Show a 'Preview Size' Button that calls a Supabase Edge Function evaluate-segment with the current rules and returns an estimated member count
```

> Pro tip: Store the rule builder form state in React useState as the SegmentRules object directly. This makes serialization to JSONB trivial — just JSON.stringify the state before saving.

**Expected result:** The segment editor renders a dynamic rule builder. Adding conditions updates the in-memory rules object. Saving writes the JSONB rules to Supabase. Preview Size returns an estimated count.

### 3. Build the nightly segment computation Edge Function

Create a Supabase Edge Function that iterates over all active segments, evaluates their JSONB rules against the user_events table, and updates user_segment_memberships. This runs nightly to keep segment membership current.

```
// supabase/functions/compute-segments/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const corsHeaders = { 'Content-Type': 'application/json' }

serve(async (req: Request) => {
  // Allow manual trigger via POST with optional segment_id filter
  const body = req.method === 'POST' ? await req.json().catch(() => ({})) : {}
  const segmentIdFilter = body.segment_id as string | undefined

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
  )

  // Fetch active segments
  let query = supabase.from('segments').select('*').eq('is_active', true)
  if (segmentIdFilter) query = query.eq('id', segmentIdFilter)
  const { data: segments, error } = await query
  if (error) return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: corsHeaders })

  const results: Record<string, number> = {}

  for (const segment of segments ?? []) {
    try {
      // Evaluate segment rules — simplified example for event_count conditions
      const rules = segment.rules as { operator: string, groups: Array<{ conditions: Array<{ attribute: string, event?: string, operator: string, value: number, timeframe_days?: number }> }> }

      // Get all users who qualify
      const qualifyingUsers = await evaluateSegmentRules(supabase, rules)

      // Get current members
      const { data: currentMembers } = await supabase
        .from('user_segment_memberships')
        .select('user_id')
        .eq('segment_id', segment.id)

      const currentSet = new Set((currentMembers ?? []).map(m => m.user_id))
      const newSet = new Set(qualifyingUsers)

      // Add new members
      const toAdd = qualifyingUsers.filter(uid => !currentSet.has(uid))
      if (toAdd.length > 0) {
        await supabase.from('user_segment_memberships').insert(
          toAdd.map(uid => ({ user_id: uid, segment_id: segment.id, assigned_at: new Date().toISOString() }))
        )
      }

      // Remove former members
      const toRemove = [...currentSet].filter(uid => !newSet.has(uid))
      if (toRemove.length > 0) {
        await supabase.from('user_segment_memberships')
          .delete()
          .eq('segment_id', segment.id)
          .in('user_id', toRemove)
      }

      const memberCount = qualifyingUsers.length
      await supabase.from('segments').update({ member_count: memberCount, recomputed_at: new Date().toISOString() }).eq('id', segment.id)
      results[segment.name] = memberCount
    } catch (err) {
      results[`${segment.name}_error`] = -1
    }
  }

  return new Response(JSON.stringify({ computed: results }), { headers: corsHeaders })
})

async function evaluateSegmentRules(supabase: ReturnType<typeof createClient>, rules: any): Promise<string[]> {
  // Simplified: handles event_count conditions only
  const allUserIds = new Set<string>()
  for (const group of rules.groups) {
    const groupUserIds = await evaluateConditionGroup(supabase, group)
    groupUserIds.forEach(id => allUserIds.add(id))
  }
  return [...allUserIds]
}

async function evaluateConditionGroup(supabase: ReturnType<typeof createClient>, group: any): Promise<string[]> {
  let resultSet: Set<string> | null = null
  for (const condition of group.conditions) {
    const users = await evaluateCondition(supabase, condition)
    const userSet = new Set(users)
    resultSet = resultSet === null ? userSet : new Set([...resultSet].filter(x => userSet.has(x)))
  }
  return [...(resultSet ?? [])]
}

async function evaluateCondition(supabase: ReturnType<typeof createClient>, condition: any): Promise<string[]> {
  const since = condition.timeframe_days
    ? new Date(Date.now() - condition.timeframe_days * 86400000).toISOString()
    : new Date(0).toISOString()

  const { data } = await supabase.rpc('get_users_by_event_count', {
    p_event_name: condition.event,
    p_min_count: condition.value,
    p_since: since,
  })
  return (data ?? []).map((r: any) => r.user_id)
}
```

**Expected result:** The Edge Function runs and populates user_segment_memberships based on event data. The segments table has updated member_count and recomputed_at values.

### 4. Build the A/B test manager and variant assignment

Create the A/B testing UI and the deterministic variant assignment hook. Test managers can create tests, configure traffic splits, and see live impression/conversion counts. The frontend hook assigns users to variants using a hash of their user ID.

```
Build two things:

1. A/B Test manager page at src/pages/AbTests.tsx:
- List all ab_tests as Cards showing: name, status Badge (draft=gray, running=green, stopped=red), slot_key, traffic split as a horizontal progress bar
- Each running test shows: total impressions, total conversions, conversion rate per variant as a Recharts BarChart
- 'Create Test' Button opens a Dialog:
  - Test name Input
  - Content slot Select (from content_slots table)
  - Variant configuration: add up to 4 variants each with a key (text) and percentage (number). Percentages must sum to 100 — show validation error if not
  - Content editor per variant: a Textarea for the variant's content JSON
- 'Start Test' and 'Stop Test' Buttons that update the status field

2. A hook at src/hooks/useAbTest.ts:
- Accepts testId: string and userId: string
- Looks up the test in ab_tests table
- Checks user_ab_assignments for an existing assignment
- If no assignment exists, uses a deterministic hash: variant index = sum(userId.charCodeAt(i)) % total_weight, maps to a variant based on traffic_split percentages
- Inserts the assignment to user_ab_assignments
- Logs an 'ab_test_impression' user_event
- Returns the assigned variant's content
- Export a logConversion(testId, userId) function that logs an 'ab_test_conversion' event
```

**Expected result:** Test managers can create and manage A/B tests. The useAbTest hook deterministically assigns users to variants. Impressions increment as users encounter tests. Conversions can be logged from any component.

### 5. Build the personalized content renderer

Create the usePersonalization hook that fetches the current user's segment memberships and returns the highest-priority content variant for any given slot. Build a demo PersonalizedHero component that shows different headline copy per segment.

```
Build two things:

1. Hook at src/hooks/usePersonalization.ts:
- On mount, fetch the current user's segment IDs from user_segment_memberships WHERE user_id = auth.uid()
- Also check user_ab_assignments for active tests
- Expose a function getVariant(slotKey: string): ContentVariant that:
  1. Fetches content_variants WHERE slot_key = slotKey
  2. Among variants with a segment_id, finds those matching the user's segments
  3. Returns the highest priority matching variant, or the default variant (segment_id IS NULL) as fallback
  4. Caches results in a Map keyed by slotKey to avoid re-fetching on every render
- Expose loading and error states

2. Demo page at src/pages/PersonalizedDemo.tsx:
- Uses usePersonalization hook
- Shows a hero section where the heading, subheading, and CTA text all come from getVariant('hero_heading'), getVariant('hero_subheading'), getVariant('hero_cta')
- While loading, shows skeleton Skeletons for each section
- Shows a debug panel (only in dev mode) listing the user's current segments as Badges
- Add a 'Track Event' Button that calls supabase.from('user_events').insert({ event_name: 'demo_cta_click', user_id: user.id, properties: {} }) and shows a Toast confirmation
```

**Expected result:** The PersonalizedDemo page shows different content based on the current user's segments. Users in different segments see different hero copy. The debug panel shows segment memberships.

## Complete code example

File: `src/hooks/usePersonalization.ts`

```typescript
import { useState, useEffect, useCallback, useRef } from 'react'
import { supabase } from '@/lib/supabase'
import { useAuth } from '@/hooks/useAuth'

interface ContentVariant {
  id: string
  slot_key: string
  segment_id: string | null
  content: Record<string, unknown>
  priority: number
}

interface PersonalizationState {
  segmentIds: string[]
  variants: Map<string, ContentVariant>
  loading: boolean
  error: string | null
}

export function usePersonalization() {
  const { user } = useAuth()
  const [state, setState] = useState<PersonalizationState>({
    segmentIds: [],
    variants: new Map(),
    loading: true,
    error: null,
  })
  const variantCache = useRef<Map<string, ContentVariant>>(new Map())

  useEffect(() => {
    if (!user?.id) {
      setState(s => ({ ...s, loading: false }))
      return
    }

    async function loadSegments() {
      const { data, error } = await supabase
        .from('user_segment_memberships')
        .select('segment_id')
        .eq('user_id', user!.id)

      if (error) {
        setState(s => ({ ...s, error: error.message, loading: false }))
        return
      }

      const segmentIds = (data ?? []).map(r => r.segment_id)
      setState(s => ({ ...s, segmentIds, loading: false }))
    }

    loadSegments()
  }, [user?.id])

  const getVariant = useCallback(
    async (slotKey: string): Promise<ContentVariant | null> => {
      if (variantCache.current.has(slotKey)) {
        return variantCache.current.get(slotKey)!
      }

      const { data: allVariants } = await supabase
        .from('content_variants')
        .select('*')
        .eq('slot_key', slotKey)
        .order('priority', { ascending: false })

      if (!allVariants?.length) return null

      const segmentVariants = allVariants.filter(
        v => v.segment_id && state.segmentIds.includes(v.segment_id)
      )

      const chosen =
        segmentVariants[0] ?? allVariants.find(v => v.segment_id === null) ?? null

      if (chosen) variantCache.current.set(slotKey, chosen)
      return chosen
    },
    [state.segmentIds]
  )

  const trackEvent = useCallback(
    async (eventName: string, properties: Record<string, unknown> = {}) => {
      if (!user?.id) return
      await supabase.from('user_events').insert({
        user_id: user.id,
        event_name: eventName,
        properties,
        page_url: window.location.href,
      })
    },
    [user?.id]
  )

  return { ...state, getVariant, trackEvent }
}
```

## Common mistakes

- **Computing segment membership on every page load** — Evaluating segment rules against the full event log on every request is extremely slow and expensive at scale. A user visiting 50 pages triggers 50 full segment computations. Fix: Use the batch nightly computation pattern. The compute-segments Edge Function populates user_segment_memberships once per day. The frontend reads from this pre-computed table, which is a simple indexed lookup taking milliseconds.
- **Not indexing the user_events table by user_id and created_at** — Segment rules filter events by user and time window. Without an index, every rule evaluation scans the full events table — catastrophically slow once you have millions of events. Fix: Add: CREATE INDEX idx_events_user_name ON user_events(user_id, event_name, created_at DESC). For property filtering, also add the GIN index on the properties JSONB column.
- **Using a random variant assignment instead of deterministic hashing** — If variant assignment is random, the same user might see variant A on one visit and variant B on the next, polluting A/B test results and creating a confusing user experience. Fix: Use a deterministic hash of the user ID and test ID to always produce the same variant for the same user. Store the assignment in user_ab_assignments on first encounter so the hash computation only happens once per user per test.
- **Allowing RLS bypass on user_events for anonymous users** — If you enable INSERT for unauthenticated users to capture anonymous events, you need to prevent abuse — a bot could flood the table with millions of fake events. Fix: Rate-limit anonymous event ingestion through an Edge Function that checks the client IP. For authenticated users, use Supabase RLS with user_id = auth.uid(). Consider only tracking anonymous events for page views, not for segment-affecting actions.

## Best practices

- Keep content variants as pure data (JSONB) rather than as React components stored in the database. The database stores text, numbers, and image URLs — the Lovable frontend maps these to components. This separates content from presentation.
- Use the priority column on content_variants to handle the case where a user is in multiple segments that all have a variant for the same slot. The highest-priority variant wins — no ambiguity.
- Add a valid_from and valid_until timestamp to content_variants so you can schedule seasonal personalization (holiday sale copy, new year promotions) without manual toggling.
- Log event_name values using a consistent naming convention like 'noun_verb' (e.g. 'pricing_page_viewed', 'trial_started', 'plan_upgraded'). Inconsistent naming makes segment rules hard to build and audit.
- Run A/B tests for at minimum 2 weeks and wait for statistical significance before declaring a winner. Do not stop a test early just because one variant is leading — early leaders often reverse.
- Add a holdout group to every A/B test — a small percentage of users (5%) who always see the control. This lets you measure the cumulative effect of all personalizations over time.
- Archive rather than delete old segments and A/B tests. Historical data in user_segment_memberships and user_ab_assignments is valuable for understanding long-term trends and cannot be reconstructed after deletion.

## Frequently asked questions

### What is the difference between a segment and an A/B test in this system?

A segment is a group of users defined by their behavior — they get a specific content variant permanently as long as they're in the segment. An A/B test randomly splits users between variants to measure which performs better. Segments drive permanent personalization; A/B tests drive controlled experiments. You can combine them by running an A/B test only within a specific segment.

### How do I track events from the Lovable frontend?

Use the trackEvent function returned by usePersonalization. Call it in onClick handlers, useEffect hooks for page views, or after form submissions. Events are inserted directly to user_events via Supabase client. For anonymous users (before sign-in), you can still track events using a session_id stored in localStorage and attach the user_id retroactively after sign-in.

### Can I use this system without the nightly Edge Function by computing segments on demand?

Yes, but only for small datasets. If you have fewer than 1,000 users and fewer than 100,000 events, on-demand segment evaluation is fast enough. Call the compute-segments Edge Function with a specific segment_id after any significant user action. For larger scale, the nightly batch approach is required to stay within Supabase Edge Function timeout limits.

### How do I measure whether personalization is actually working?

Compare conversion rates between personalized and non-personalized users using the A/B test holdout group (5% always sees default content). Track your primary conversion event (e.g. 'trial_started') as an A/B test conversion. After 2+ weeks of data, compare conversion rates between the control (no personalization) and treatment (personalized) groups to calculate lift.

### What GDPR considerations apply to this event tracking system?

You must disclose event tracking in your privacy policy. For EU users, obtain consent before logging behavioral events (use a consent management component). Add a user data deletion function that removes all rows from user_events, user_segment_memberships, and user_ab_assignments for a given user_id. Do not log personally identifiable information in the event properties JSON — log IDs and anonymized attributes instead.

### Is RapidDev available to help build a more sophisticated personalization engine?

Yes. RapidDev builds production personalization systems including real-time segment evaluation, machine learning-based scoring, and multi-channel delivery (web, email, push). Reach out if your personalization needs go beyond rule-based segments.

### How do I prevent the same user from being assigned to a segment indefinitely?

Add an expires_at column to user_segment_memberships. When the nightly compute-segments function runs, it can set expires_at based on the segment's recency window. For example, a 'high-intent' segment based on events in the last 30 days should expire memberships that no longer qualify rather than leaving them indefinitely. The getVariant function should check that the membership has not expired.

### Can content variants store full React component configurations?

Content variants store JSONB data — text, numbers, image URLs, and nested objects. The React components in your Lovable app map this data to visual elements. For example, a hero variant might store { heading: 'Try for free', cta_text: 'Start now', cta_color: 'blue' } and your PersonalizedHero component reads these values and applies them. This keeps content manageable without touching code for every copy change.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/personalization-system
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/personalization-system
