# How to Add an User-to-User Recommendation System to Your App (Copy-Paste Prompts)

- Tool: App Features
- Last updated: July 2026

## TL;DR

A user-to-user recommendation system needs four pieces: a recommend button with Fuse.js recipient search, a polymorphic recommendations table that works for any content type (products, articles, profiles), a Supabase Edge Function that triggers an in-app notification on each INSERT, and an inbox where recipients see what's been shared. Ship it with Lovable or V0 in 6-10 hours for $0/month on the free tier. AI tools frequently omit the self-recommendation guard — always check.

## What a User-to-User Recommendation System Actually Requires

A recommendation system where users share items with each other is architecturally different from algorithmic recommendations. You need three surfaces: the recommend action (a button on any content item that opens a recipient picker), the notification delivery (a Supabase Edge Function triggered on INSERT that pushes a badge update to the recipient in real time via Supabase Realtime), and the recommendations inbox (a dedicated page or panel where users see what has been shared with them, mark items as read, and dismiss or save recommendations). The polymorphic data model — where recommendations reference any content type via item_type and item_id — is what makes the feature extensible across your whole app rather than locked to one section.

## Anatomy of the Feature

Six components. The recommend button and inbox UI generate reliably. The polymorphic item_type pattern, Fuse.js recipient combobox debounce, and self-recommendation guard are where first builds fail.

- **Recommend action button** (ui): shadcn/ui Button (secondary, small) placed on any content card or detail page. On click, opens a Popover with: a combobox for recipient search (Fuse.js fuzzy matching over a preloaded list of followees or all users, 300ms debounce), an optional note textarea (max 280 characters), and a confirm 'Recommend' button. The send button is disabled when sender === recipient.
- **Recommendation record** (data): Supabase recommendations table with polymorphic item reference: sender_id, recipient_id, item_type (text enum: 'product', 'article', 'user', 'post', etc.), item_id (UUID), note (text, max 280 chars), created_at, read_at. A database-level CHECK constraint prevents self-recommendation (sender_id != recipient_id). An item_snapshot_title column stores a copy of the item's display name at send time to prevent orphan recommendations if content is deleted.
- **In-app notification** (backend): Supabase Database Webhook on recommendations table INSERT triggers a Supabase Edge Function. The Edge Function inserts a row into a notifications table (user_id = recipient_id, type = 'recommendation', payload JSONB containing sender name, item title, and note). Supabase Realtime Postgres Changes subscription on notifications table (filtered by user_id) updates the recipient's notification badge count in real time.
- **Recommendations inbox** (ui): Dedicated /recommendations route (or an inbox panel accessible from the nav badge). Lists received recommendations grouped by unread/read, newest first. Each row shows sender avatar, item preview card (title, thumbnail), note, and relative timestamp. Mark-as-read on row click (UPDATE read_at = now()). Dismiss button (soft-delete with dismissed_at column). Bulk mark-all-read action.
- **Recommendation analytics** (data): Aggregate queries over the recommendations table: most recommended items (SELECT item_id, item_type, COUNT(*) GROUP BY item_id, item_type ORDER BY count DESC), top recommenders, and conversion rate (percentage of recommendations where recipient subsequently viewed or purchased the item). Exposed via Supabase RPC functions or a Supabase materialized view refreshed daily.
- **Email digest (optional)** (service): Supabase Edge Function on a weekly pg_cron schedule queries unread recommendations per user, generates an email summary using React Email templates, and sends via Resend API. Email includes sender names, item thumbnails, and a 'View in app' CTA link. Subject: 'You have N new recommendations from [friends]'.

## Data model

Two tables: recommendations (the core record) and notifications (the real-time delivery mechanism). Run this in the Supabase SQL Editor (Dashboard → SQL Editor):

```sql
create table public.recommendations (
  id uuid primary key default gen_random_uuid(),
  sender_id uuid references auth.users(id) on delete cascade not null,
  recipient_id uuid references auth.users(id) on delete cascade not null,
  item_type text not null,
  item_id uuid not null,
  item_snapshot_title text,
  note text check (length(note) <= 280),
  created_at timestamptz not null default now(),
  read_at timestamptz,
  dismissed_at timestamptz,
  constraint no_self_recommendation check (sender_id != recipient_id)
);

create table public.notifications (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  type text not null,
  payload jsonb not null default '{}',
  created_at timestamptz not null default now(),
  read_at timestamptz
);

alter table public.recommendations enable row level security;
alter table public.notifications enable row level security;

create policy "Sender can insert recommendations"
  on public.recommendations for insert
  with check (auth.uid() = sender_id);

create policy "Recipient can read their received recommendations"
  on public.recommendations for select
  using (auth.uid() = recipient_id);

create policy "Sender can read their sent recommendations"
  on public.recommendations for select
  using (auth.uid() = sender_id);

create policy "Recipient can update read_at and dismissed_at"
  on public.recommendations for update
  using (auth.uid() = recipient_id)
  with check (auth.uid() = recipient_id);

create policy "Users can read their own notifications"
  on public.notifications for select
  using (auth.uid() = user_id);

create policy "Users can update their own notifications read_at"
  on public.notifications for update
  using (auth.uid() = user_id);

create index recommendations_recipient_idx
  on public.recommendations (recipient_id, created_at desc);

create index recommendations_recipient_unread_idx
  on public.recommendations (recipient_id, read_at)
  where read_at is null;

create index recommendations_sender_idx
  on public.recommendations (sender_id, created_at desc);

create index notifications_user_id_idx
  on public.notifications (user_id, created_at desc);

create index notifications_user_unread_idx
  on public.notifications (user_id, read_at)
  where read_at is null;
```

The no_self_recommendation CHECK constraint is enforced at the database level — even if the client-side button is disabled, a direct API call cannot insert a self-recommendation. The item_snapshot_title column preserves the display name at send time; if the original content is deleted, the recommendation inbox still shows a meaningful title.

## Build paths

### Lovable — fit 4/10, 6-8 hours

Strong fit — Supabase Realtime notification pattern is Lovable's home turf. The recommend button and inbox UI generate cleanly. The Fuse.js recipient combobox and polymorphic item_type sometimes need a follow-up prompt to get the debounce and self-recommendation guard correct.

1. Create a Lovable project with Lovable Cloud enabled so Supabase auth and the recommendations/notifications schema are provisioned automatically
2. Paste the prompt below; Lovable will generate the recommend button Popover, recipient combobox, inbox page, and notification badge
3. After generation, verify the generated recommend button has the self-recommendation guard: check that the confirm button is disabled when the selected recipient ID equals auth.uid()
4. Check that the Fuse.js search uses the preloaded users array with a 300ms debounce — not a live Supabase query on every keystroke
5. Set up the Supabase Database Webhook in the Supabase Dashboard (Database → Webhooks) pointing to your Edge Function URL to trigger notification delivery on recommendation INSERT

Starter prompt:

```
Build a user-to-user recommendation system. Tables: recommendations (id, sender_id, recipient_id, item_type text, item_id uuid, item_snapshot_title text, note text max 280 chars, created_at, read_at, dismissed_at; CHECK sender_id != recipient_id) and notifications (id, user_id, type, payload jsonb, created_at, read_at). RLS: sender can INSERT; recipient can SELECT and UPDATE read_at/dismissed_at on recommendations; user can SELECT and UPDATE read_at on their own notifications. Recommend button component: a small 'Recommend' button that opens a shadcn/ui Popover; inside the Popover, a combobox input that uses Fuse.js to fuzzy-search over a preloaded array of user objects (id, display_name, avatar_url) with 300ms debounce; disable and hide the current user from the recipient list to prevent self-recommendation; a note textarea (max 280 chars with live character counter); a 'Send recommendation' button that inserts into recommendations with item_type, item_id, item_snapshot_title, note, and recipient_id; shows a success toast on completion and resets the Popover. This component should accept props: itemType (string), itemId (uuid), itemTitle (string). Recommendations inbox page at /recommendations: lists received recommendations grouped into 'Unread' and 'Read' sections; each row shows sender avatar, item_snapshot_title as a clickable link to the item, note text, relative timestamp; clicking a row sets read_at = now() via Supabase update; a 'Dismiss' button sets dismissed_at = now(); a 'Mark all read' button. Notification badge: a bell icon in the nav bar with a count of unread notifications; Supabase Realtime Postgres Changes subscription on notifications table filtered by user_id; count decrements when notifications are read. Edge Function: triggered by Supabase Database Webhook on recommendations INSERT; inserts into notifications table with type = 'recommendation' and payload = { sender_name, item_title, item_type, item_id, note }. Sent tab on the inbox showing recommendations the user has sent, with recipient name and read status.
```

Limitations:

- Fuse.js recipient combobox may not include the 300ms debounce automatically — review the generated search handler and add if missing
- The Database Webhook to Edge Function trigger requires manual setup in Supabase Dashboard (Database → Webhooks); Lovable's generated code assumes the Edge Function is deployed
- Polymorphic item_id has no foreign key enforcement at the DB level — orphan recommendations appear if content is deleted; the item_snapshot_title column mitigates the display issue but does not fix referential integrity

### V0 — fit 4/10, 7-10 hours

Excellent component output for the inbox and recommend modal. Server Actions for insert keep the recommendation logic server-side. The notification badge needs a 'use client' component that V0 usually gets right.

1. Prompt V0 with the spec below; it will generate the RecommendButton, RecipientCombobox, RecommendationsInbox, and NotificationBadge components
2. Open the Vars panel and add NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY
3. Run the SQL from af_data_model in your Supabase SQL Editor
4. Deploy the Supabase Edge Function for notification delivery and configure the Database Webhook in Supabase Dashboard
5. Verify the NotificationBadge is a 'use client' component with a useEffect-mounted Supabase Realtime subscription — if V0 placed it in a Server Component, move it to a client component

Starter prompt:

```
Build a user-to-user recommendation system in Next.js. Component 1 (use client): RecommendButton — accepts props itemType, itemId, itemTitle; opens a shadcn/ui Popover with a recipient ComboBox that loads users via a Server Action and filters client-side with Fuse.js (300ms debounce); excludes current user from recipient list; note textarea with 280 char limit and live counter; confirm button calls a 'sendRecommendation' Server Action that inserts into Supabase recommendations table with item_snapshot_title; shows toast on success; resets Popover on close. Server Action guards: sender_id = auth.uid(), recipient must be a different user (return error if sender_id = recipient_id). Component 2 (use client): RecommendationsInbox — server component page at /recommendations that fetches received recommendations via Supabase (ordered by created_at desc); renders two sections: Unread (read_at is null) and Read; each recommendation card shows sender name, item_snapshot_title (linked), note, timestamp; click to mark read via Server Action; Dismiss button sets dismissed_at; Mark all read button. Component 3 (use client): NotificationBadge — bell icon with unread count; subscribes to Supabase Realtime Postgres Changes on notifications table filtered by user_id=eq.{userId}; increments count on INSERT event; sets count to 0 and updates read_at on click; renders as 0 when no unread. Sent tab: shows recommendations the current user has sent with recipient name, item title, sent time, and whether recipient has read it (read_at is not null). Empty state for inbox: 'No recommendations yet — explore content and recommend items to your friends'.
```

Limitations:

- V0 won't auto-provision the Supabase schema — run the SQL manually in Supabase SQL Editor and deploy the notification Edge Function separately
- The notification badge must be in a 'use client' component — if V0 places it in the Server Component layout, extract it to a separate client wrapper
- The Database Webhook for notification delivery requires manual configuration in Supabase Dashboard after the Edge Function is deployed

### Custom — fit 5/10, 1-2 weeks

Full control over recommendation ML scoring, collaborative filtering extensions, CRM export, reputation gamification, and complex privacy controls for private accounts.

1. Extend the recommendations table with a conversion_event_at column and wire it to your analytics events (view, purchase, save) to compute recommendation conversion rates per sender
2. Add a reputation system: a materialized view aggregating recommendation_count, conversion_rate, and avg_recipient_engagement per user, updated nightly via pg_cron; display as a 'Top Recommender' badge on profile pages
3. Implement collaborative filtering as an extension: when user A recommends item X to user B, also surface item X to other users in A and B's social graph using a Supabase RPC function weighted by recommendation count
4. Add privacy controls: private accounts require the sender to be a confirmed follower before they can send a recommendation; multi-step approval flow where recipient must accept before seeing recommendation content

Limitations:

- Collaborative filtering and ML-based recommendation scoring require data science infrastructure (Python, numpy, trained models) that takes 3-4 weeks beyond the core feature to build well

## Gotchas

- **Self-recommendation not blocked — users recommend items to themselves** — AI tools commonly omit the CHECK (sender_id != recipient_id) database constraint. The client-side button disables itself when sender matches recipient, but a direct Supabase insert (via API or a third-party tool) bypasses the UI guard and creates self-recommendation rows. Self-recommendations pollute the inbox and create confusing notification badges. Fix: Add the CHECK constraint at the DB level: ALTER TABLE recommendations ADD CONSTRAINT no_self_recommendation CHECK (sender_id != recipient_id). Additionally, add the guard in the RLS INSERT policy and in the Server Action: if (senderId === auth.uid() && senderId === recipientId) return { error: 'Cannot recommend to yourself' }.
- **Orphaned recommendations when content is deleted** — The polymorphic item_id references content in another table (products, articles, posts) but has no FK constraint. When content is deleted, the recommendation row in the inbox shows a dead item_id — the UI either crashes trying to render a null item or shows a blank card. Fix: Store item_snapshot_title (and optionally item_snapshot_url) in the recommendations row at INSERT time. Render the inbox using item_snapshot_title as the fallback when the live item lookup returns null. For more robust referential integrity, add ON DELETE SET NULL triggers per item_type or implement soft-delete on content items.
- **Notification badge does not update in real time in V0/Next.js** — The notification badge lives in the site's root navigation layout (app/layout.tsx in Next.js App Router). V0 commonly generates the badge as a Server Component that fetches the unread count at request time. The Supabase Realtime subscription that makes the badge live requires a 'use client' component, which cannot be the root layout itself. Fix: Extract the notification badge into a separate 'use client' NotificationBadge component: it initializes the unread count via a Supabase query in useEffect and subscribes to Realtime Postgres Changes on the notifications table filtered by the user's ID. Import it into the layout file — the layout stays a Server Component while the badge alone is a client island.
- **Recommendation note length not validated — massive text floods inbox** — AI tools often generate the note textarea without a maxLength attribute or a Zod schema constraint. Users can paste entire articles (10,000+ characters) as recommendation notes. These entries consume disproportionate database space and make the inbox UI break on long-text rendering. Fix: Add three-layer validation: maxLength={280} on the textarea element, a Zod schema check (z.string().max(280).optional()) on the Server Action, and a database-level constraint CHECK (length(note) <= 280) on the recommendations table.
- **Recommendations inbox is slow at scale due to missing indexes** — SELECT * FROM recommendations WHERE recipient_id = $1 AND dismissed_at IS NULL ORDER BY created_at DESC without an index performs a full table scan once the recommendations table has tens of thousands of rows. At 10,000 users who recommend frequently, this query takes 2-5 seconds. Fix: Add a composite index: CREATE INDEX recommendations_recipient_idx ON recommendations (recipient_id, created_at DESC). Add a partial index for the unread count: CREATE INDEX recommendations_recipient_unread_idx ON recommendations (recipient_id, read_at) WHERE read_at IS NULL. Both are included in the SQL schema above.

## Best practices

- Always enforce sender_id != recipient_id both as a database CHECK constraint and in the Server Action — the UI guard alone is insufficient and easily bypassed
- Store item_snapshot_title at INSERT time so the recommendations inbox remains meaningful even after content deletion — never rely solely on a live item lookup to render the inbox
- Use Fuse.js client-side fuzzy search with a preloaded users array instead of running a Supabase query on every keystroke in the recipient combobox — the preloaded approach is faster and doesn't hit Supabase rate limits
- Add a 300ms debounce on the Fuse.js search input so the results list doesn't flicker as the user types
- Index (recipient_id, created_at DESC) and add a partial index on (recipient_id, read_at) WHERE read_at IS NULL — the unread count query runs on every notification badge render and must be fast
- Send the weekly email digest only to users with at least one unread recommendation — never send an empty 'You have 0 recommendations' email
- Show an empty state with a CTA on the recommendations inbox ('No recommendations yet — start exploring content and recommend things to your network') to drive engagement rather than showing a blank list

## Frequently asked questions

### What is the difference between a recommendation and a referral?

A recommendation is a social, content-focused action: one user shares an item they like with another user, no reward involved. A referral is a business incentive mechanism: user A recruits user B to sign up, and user A gets a reward when user B converts. They use different data models — recommendations track item + note, referrals track conversion events and reward payouts. Build them as separate features; they share no code.

### How do I show users who recommended something to them?

Every recommendation row stores sender_id. Join recommendations to a profiles or users table on sender_id to get the display name and avatar. In the recommendations inbox, render the sender's avatar and name on each card. You can also add an 'X people recommended this' count on the item detail page by querying SELECT COUNT(*), array_agg(sender_id) FROM recommendations WHERE item_id = $1 AND item_type = $2.

### Can recommendations work for multiple content types (products, posts, users)?

Yes — the polymorphic (item_type, item_id) pattern handles this. Store item_type as a text enum ('product', 'article', 'user', 'post') and item_id as a UUID that references the appropriate table. The recommendations table has no foreign keys to content tables; instead, when rendering the inbox you do a lookup per item_type to fetch the current item data (or fall back to item_snapshot_title if the item was deleted).

### How do I prevent spam recommendations?

Add a rate limit: in the Server Action that handles recommendation inserts, query the count of recommendations sent by sender_id in the last 24 hours. If count >= 20 (or your chosen limit), reject the insert with a user-friendly message. Store the limit config in an app_config table so you can adjust it without redeployment. Also enforce unique constraint on (sender_id, recipient_id, item_id, item_type) to prevent the same recommendation being sent repeatedly.

### Can I track whether a recommendation led to a purchase?

Yes — add a conversion_event_at column to the recommendations table and a conversion_type column ('view', 'purchase', 'save'). When the recipient views, purchases, or saves the recommended item, call a Server Action that sets conversion_event_at = now() and conversion_type on the relevant recommendation row. Compute conversion rate per sender using: SELECT COUNT(*) FILTER (WHERE conversion_event_at IS NOT NULL) / COUNT(*) FROM recommendations WHERE sender_id = $1.

### How do I build a recommendations inbox similar to LinkedIn's?

The LinkedIn pattern is: left panel (nav badge with unread count, links to inbox) and right content area (full recommendation list with sender, item preview, note, and action buttons). Build it as a /recommendations page with a badge in the main nav. Group into 'New' (read_at IS NULL) and 'Earlier' sections. Each card has sender avatar, item preview thumbnail, note, timestamp, and action buttons (View item, Dismiss). Mark read automatically on row click via a Server Action UPDATE.

### Should recommendations be private or visible to everyone?

Recommendations are inherently private (sender to specific recipient) in this model. If you want public recommendations visible to all users (e.g., 'Alex recommends this book'), that is a review or endorsement feature with a different schema — a public recommendations table with no recipient_id, displayed on the item detail page. You can build both: private recommendations to a friend and a separate public endorsement.

### How do I send email notifications for new recommendations?

In the Supabase Edge Function that runs on recommendation INSERT, after inserting the notification row, call the Resend API with a React Email template. The email should include the sender's name, the item title (from item_snapshot_title), the note, and a 'View in app' link to /recommendations?highlight={recommendationId}. Set the reply-to header to a no-reply address and include an unsubscribe link. Use Resend's free tier (3,000 emails/mo) for apps under 1,000 active users.

---

Source: https://www.rapidevelopers.com/app-features/user-to-user-recommendation-system
© RapidDev — https://www.rapidevelopers.com/app-features/user-to-user-recommendation-system
