Skip to main content
RapidDev - Software Development Agency
App Featurescommunication-social21 min read

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

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.

4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members

Feature spec

Intermediate

Category

communication-social

Build with AI

6-10 hours with Lovable or V0

Custom build

1-2 weeks custom dev

Running cost

$0/mo up to 1,000 users · $25-75/mo at 10K users

Works on

Web

Everything it takes to ship an User to User Recommendation System — parts, prompts, and real costs.

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.

What users consider table stakes in 2026

  • One-click 'Recommend to a friend' action from any content item — the action should be no more than 2 clicks from the item
  • Recipient selection via a searchable combobox with fuzzy matching so users can find friends by partial name — not a dropdown of all users
  • Optional personal note field so the recommendation carries context ('thought you'd love this because...')
  • Recipient sees an in-app notification badge update in real time — not just on next page load
  • Dedicated recommendations inbox with unread/read state, sender avatar, item preview, and note visible at a glance
  • Ability to dismiss a recommendation or save it to a list — unread recommendations should not feel like an obligation

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.

Layers:UIDataBackendService

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.

Note: Preload the users list once on component mount (limit to followees or friends list for apps with social graphs). Do not run a Supabase query on every keystroke — debounce Fuse.js client-side search over the preloaded array instead.

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.

Note: The polymorphic (item_type, item_id) pattern avoids a separate recommendations table per content type. Store item_snapshot_title at insert time — if the original content is deleted, the inbox still shows what was recommended.

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.

Note: Using a Database Webhook trigger instead of a direct Edge Function call decouples the recommendation INSERT from the notification delivery — if the notification fails, the recommendation is still saved. The payload JSONB should include all data needed to render the notification without an additional query.

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.

Note: Use an index on (recipient_id, created_at DESC) and a separate index on (recipient_id, read_at) for the unread count query. Without these, the inbox query scans the full recommendations table for large apps.

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.

Note: Materialized view is preferred over a live aggregate query for the 'most recommended' leaderboard — a real-time COUNT(*) GROUP BY over a large recommendations table is slow and unnecessary for a leaderboard that changes gradually.

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]'.

Note: Only send the digest to users who have unread recommendations at send time. Batch users to avoid hitting Resend rate limits — process 100 users per Edge Function invocation with cursor pagination.

The 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):

schema.sql
1create table public.recommendations (
2 id uuid primary key default gen_random_uuid(),
3 sender_id uuid references auth.users(id) on delete cascade not null,
4 recipient_id uuid references auth.users(id) on delete cascade not null,
5 item_type text not null,
6 item_id uuid not null,
7 item_snapshot_title text,
8 note text check (length(note) <= 280),
9 created_at timestamptz not null default now(),
10 read_at timestamptz,
11 dismissed_at timestamptz,
12 constraint no_self_recommendation check (sender_id != recipient_id)
13);
14
15create table public.notifications (
16 id uuid primary key default gen_random_uuid(),
17 user_id uuid references auth.users(id) on delete cascade not null,
18 type text not null,
19 payload jsonb not null default '{}',
20 created_at timestamptz not null default now(),
21 read_at timestamptz
22);
23
24alter table public.recommendations enable row level security;
25alter table public.notifications enable row level security;
26
27create policy "Sender can insert recommendations"
28 on public.recommendations for insert
29 with check (auth.uid() = sender_id);
30
31create policy "Recipient can read their received recommendations"
32 on public.recommendations for select
33 using (auth.uid() = recipient_id);
34
35create policy "Sender can read their sent recommendations"
36 on public.recommendations for select
37 using (auth.uid() = sender_id);
38
39create policy "Recipient can update read_at and dismissed_at"
40 on public.recommendations for update
41 using (auth.uid() = recipient_id)
42 with check (auth.uid() = recipient_id);
43
44create policy "Users can read their own notifications"
45 on public.notifications for select
46 using (auth.uid() = user_id);
47
48create policy "Users can update their own notifications read_at"
49 on public.notifications for update
50 using (auth.uid() = user_id);
51
52create index recommendations_recipient_idx
53 on public.recommendations (recipient_id, created_at desc);
54
55create index recommendations_recipient_unread_idx
56 on public.recommendations (recipient_id, read_at)
57 where read_at is null;
58
59create index recommendations_sender_idx
60 on public.recommendations (sender_id, created_at desc);
61
62create index notifications_user_id_idx
63 on public.notifications (user_id, created_at desc);
64
65create index notifications_user_unread_idx
66 on public.notifications (user_id, read_at)
67 where read_at is null;

Heads up: 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 it — pick your path

Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.

Hand-built by developersFit for this feature:

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

Step by step

  1. 1Extend 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. 2Add 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. 3Implement 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. 4Add 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

Where this path bites

  • 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

Third-party services you'll need

The recommendation system runs entirely on Supabase free tier for most apps. Costs only appear when notification Realtime load is high or when the weekly email digest volume exceeds Resend's free tier:

ServiceWhat it doesFree tierPaid from
SupabaseDatabase (recommendations + notifications), Realtime for notification badge, Edge Functions for notification delivery, Database Webhooks triggerFree — 500 concurrent connections, 500MB database, 500K Edge Function invocations/moPro $25/mo — 10,000 concurrent connections, 8GB database
ResendWeekly email digest of new recommendations with item previews and sender names3,000 emails/mo free$20/mo (50,000 emails) (approx)
Fuse.jsClient-side fuzzy search over the users list for the recipient combobox — runs entirely in the browser with no API call per keystrokeFree (MIT license)Free

Swipe the table sideways to see pricing.

What it costs to run

Drag through the tiers to see how your monthly bill scales with users — no surprises later.

Estimated monthly running cost

$0/mo

Supabase free tier covers all recommendations, notifications, and Realtime connections. Resend free tier (3,000 emails/mo) covers weekly digests. Fuse.js is free. Total: $0/mo.

Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.

What breaks when AI tools build this

The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.

Self-recommendation not blocked — users recommend items to themselves

Symptom: 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

Symptom: 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

Symptom: 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

Symptom: 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

Symptom: 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

1

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

2

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

3

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

4

Add a 300ms debounce on the Fuse.js search input so the results list doesn't flicker as the user types

5

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

6

Send the weekly email digest only to users with at least one unread recommendation — never send an empty 'You have 0 recommendations' email

7

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

When You Need Custom Development

AI tools handle simple user-to-user recommendation sending and inboxes well. Move to custom when:

  • Collaborative filtering is required — surfacing items to users based on what similar-taste users have recommended — this needs ML infrastructure and trained models beyond what AI tools scaffold
  • CRM integration to track recommendation-driven conversions: linking a recommendation event to a purchase or signup in HubSpot or Salesforce requires custom webhook and attribution logic
  • Reputation scoring for top recommenders as a gamification layer: a leaderboard of most influential recommenders with badges, points, and conversion rate tracking requires a custom analytics pipeline
  • Multi-step recommendation flow with privacy controls for private accounts: the sender must be a confirmed follower, the recipient must accept the recommendation before content is revealed — this approval workflow is significantly more complex than AI tools produce

RapidDev builds these features for production

Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.

Get a fixed-price quote

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.

RapidDev

Need this feature production-ready?

RapidDev builds an user to user recommendation system into real apps — auth, database, payments — at $13K–$25K.

Book a free consultation

30-min call. No commitment.

Want this built for you?

We ship production apps at a fixed price — $13K–$25K, 6–10 weeks, source code yours. You've seen what it takes; we do it every week.

Get a fixed-price quote

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.