# How to Build Recommendations engine with V0

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

## TL;DR

Build a product recommendations engine with V0 using Next.js, Supabase with pgvector, and OpenAI embeddings for collaborative and content-based filtering. Features fast cosine similarity search, cold-start fallback strategies, cached recommendations, and an admin performance dashboard — all in about 2-4 hours.

## Before you start

- A V0 account (Premium recommended for prompt queuing)
- A Supabase project with pgvector extension enabled (free tier works)
- An OpenAI API key for generating embeddings (pay-as-you-go pricing)
- A catalog of items to recommend (products, content, or any entity)

## Step-by-step guide

### 1. Set up the project and vector database schema

Open V0 and create a new project. Use the Connect panel to add Supabase. Enable pgvector extension and create the schema for items with embeddings, user interactions, preferences, and recommendation cache.

```
// Paste this prompt into V0's AI chat:
// Build a recommendations engine. Enable pgvector extension in Supabase and create schema:
// 1. items: id (uuid PK), name (text), description (text), category (text), metadata (jsonb), embedding (vector(1536)), created_at (timestamptz)
// 2. user_interactions: id (uuid PK), user_id (uuid FK), item_id (uuid FK), interaction_type (text check view/click/purchase/rating), rating (integer check 1-5 nullable), created_at (timestamptz) with index on (user_id, interaction_type)
// 3. user_preferences: id (uuid PK), user_id (uuid FK unique), preferred_categories (text[]), interaction_weights (jsonb), updated_at (timestamptz)
// 4. recommendations_cache: id (uuid PK), user_id (uuid FK), item_ids (uuid[]), algorithm (text), score (numeric[]), generated_at (timestamptz), expires_at (timestamptz)
// Create IVFFlat index: CREATE INDEX ON items USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
// RLS: users can read all items, read/write their own interactions and preferences.
// Generate SQL migration and TypeScript types.
```

**Expected result:** Supabase is connected with pgvector enabled. All four tables are created with an IVFFlat index on the embedding column for fast similarity search.

### 2. Build the embedding generation pipeline

Create an API route that generates vector embeddings for item descriptions using the OpenAI Embeddings API. This converts text descriptions into 1536-dimension vectors stored in the items table for similarity search.

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

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const { item_id } = await req.json()

  const { data: item } = await supabase
    .from('items')
    .select('name, description, category, metadata')
    .eq('id', item_id)
    .single()

  if (!item) {
    return NextResponse.json({ error: 'Item not found' }, { status: 404 })
  }

  const textToEmbed = `${item.name}. ${item.description}. Category: ${item.category}.`

  const embeddingRes = await fetch('https://api.openai.com/v1/embeddings', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      model: 'text-embedding-3-small',
      input: textToEmbed,
    }),
  })

  const embeddingData = await embeddingRes.json()
  const embedding = embeddingData.data[0].embedding

  await supabase
    .from('items')
    .update({ embedding })
    .eq('id', item_id)

  return NextResponse.json({ success: true, dimensions: embedding.length })
}
```

> Pro tip: Store OPENAI_API_KEY in the Vars tab without NEXT_PUBLIC_ prefix — it is a secret key that must only be used server-side in API routes.

**Expected result:** POST /api/embeddings/generate with an item_id generates a 1536-dimension embedding via OpenAI and stores it in the items table for similarity search.

### 3. Create the recommendation generation endpoint

Build the core recommendation API that uses pgvector cosine similarity for content-based filtering and user interaction patterns for collaborative filtering. Includes cold-start fallback for new users.

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

export const runtime = 'edge'

export async function GET(req: NextRequest) {
  const supabase = createClient(
    process.env.SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!
  )

  const userId = req.nextUrl.searchParams.get('user_id')
  const limit = parseInt(req.nextUrl.searchParams.get('limit') ?? '10')

  if (!userId) {
    return NextResponse.json({ error: 'user_id required' }, { status: 400 })
  }

  // Check cache first
  const { data: cached } = await supabase
    .from('recommendations_cache')
    .select('item_ids, algorithm')
    .eq('user_id', userId)
    .gt('expires_at', new Date().toISOString())
    .order('generated_at', { ascending: false })
    .limit(1)
    .single()

  if (cached) {
    const { data: items } = await supabase
      .from('items')
      .select('id, name, description, category, metadata')
      .in('id', cached.item_ids.slice(0, limit))

    return NextResponse.json({ items, algorithm: cached.algorithm, cached: true })
  }

  // Get user's interaction history
  const { data: interactions } = await supabase
    .from('user_interactions')
    .select('item_id, interaction_type, rating')
    .eq('user_id', userId)
    .order('created_at', { ascending: false })
    .limit(50)

  const interactedIds = interactions?.map((i) => i.item_id) ?? []

  let algorithm = 'content-based'
  let recommendedIds: string[] = []

  if (interactedIds.length >= 5) {
    // Collaborative: find similar users and recommend their items
    algorithm = 'collaborative'
    const { data: recs } = await supabase.rpc('collaborative_recommendations', {
      target_user_id: userId,
      exclude_ids: interactedIds,
      rec_limit: limit,
    })
    recommendedIds = recs?.map((r: { item_id: string }) => r.item_id) ?? []
  }

  if (recommendedIds.length < limit) {
    // Content-based fallback: find similar items to user's favorites
    const topItemId = interactedIds[0] ?? null
    if (topItemId) {
      const { data: similar } = await supabase.rpc('similar_items', {
        target_item_id: topItemId,
        exclude_ids: [...interactedIds, ...recommendedIds],
        match_count: limit - recommendedIds.length,
      })
      recommendedIds.push(...(similar?.map((s: { id: string }) => s.id) ?? []))
      algorithm = interactedIds.length >= 5 ? 'hybrid' : 'content-based'
    }
  }

  // Cache results
  if (recommendedIds.length > 0) {
    await supabase.from('recommendations_cache').insert({
      user_id: userId,
      item_ids: recommendedIds,
      algorithm,
      expires_at: new Date(Date.now() + 3600000).toISOString(),
    })
  }

  const { data: items } = await supabase
    .from('items')
    .select('id, name, description, category, metadata')
    .in('id', recommendedIds)

  return NextResponse.json({ items, algorithm, cached: false })
}
```

**Expected result:** GET /api/recommendations/generate?user_id=xxx returns personalized recommendations using collaborative filtering for active users and content-based fallback for new users, with caching.

### 4. Build the interaction tracking Server Action

Create a Server Action that records user interactions (views, clicks, purchases, ratings) on items. This data feeds both the collaborative filtering algorithm and the content-based similarity recommendations.

```
'use server'

import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'

export async function trackInteraction(input: {
  user_id: string
  item_id: string
  interaction_type: 'view' | 'click' | 'purchase' | 'rating'
  rating?: number
}) {
  const supabase = await createClient()

  await supabase.from('user_interactions').insert({
    user_id: input.user_id,
    item_id: input.item_id,
    interaction_type: input.interaction_type,
    rating: input.rating ?? null,
  })

  // Invalidate recommendations cache for this user
  await supabase
    .from('recommendations_cache')
    .delete()
    .eq('user_id', input.user_id)

  revalidatePath('/')
}
```

**Expected result:** Every user interaction is logged and the recommendation cache for that user is invalidated, ensuring fresh recommendations on next request.

### 5. Create the Supabase RPC functions for similarity search

Build the PostgreSQL functions that power the recommendation algorithms. The similar_items function uses pgvector cosine distance, and collaborative_recommendations finds items liked by similar users.

```
// Paste this prompt into V0's AI chat:
// Create Supabase SQL migration with these functions:
// 1. similar_items(target_item_id uuid, exclude_ids uuid[], match_count int):
//    SELECT id, name, description, category, 1 - (embedding <=> (SELECT embedding FROM items WHERE id = target_item_id)) as similarity
//    FROM items
//    WHERE id != target_item_id AND id != ALL(exclude_ids) AND embedding IS NOT NULL
//    ORDER BY embedding <=> (SELECT embedding FROM items WHERE id = target_item_id)
//    LIMIT match_count;
// 2. collaborative_recommendations(target_user_id uuid, exclude_ids uuid[], rec_limit int):
//    Find users who interacted with same items as target user,
//    then return items those similar users interacted with but target user has not,
//    ranked by number of similar-user interactions.
// 3. Ensure the IVFFlat index exists on items.embedding column for fast vector search.
// Also create the API route at app/api/recommendations/user/[userId]/route.ts that returns cached or fresh recommendations.
```

> Pro tip: The IVFFlat index with 100 lists gives sub-second cosine similarity search for up to 1 million items. For larger catalogs, increase the lists parameter.

**Expected result:** Two Supabase RPC functions are created: similar_items for content-based filtering and collaborative_recommendations for collaborative filtering. Both are optimized with the IVFFlat index.

## Complete code example

File: `app/api/recommendations/generate/route.ts`

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

export const runtime = 'edge'

export async function GET(req: NextRequest) {
  const supabase = createClient(
    process.env.SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!
  )

  const userId = req.nextUrl.searchParams.get('user_id')
  const limit = parseInt(req.nextUrl.searchParams.get('limit') ?? '10')

  if (!userId) {
    return NextResponse.json({ error: 'user_id required' }, { status: 400 })
  }

  const { data: cached } = await supabase
    .from('recommendations_cache')
    .select('item_ids, algorithm')
    .eq('user_id', userId)
    .gt('expires_at', new Date().toISOString())
    .limit(1)
    .single()

  if (cached) {
    const { data: items } = await supabase
      .from('items')
      .select('id, name, description, category')
      .in('id', cached.item_ids.slice(0, limit))
    return NextResponse.json({ items, algorithm: cached.algorithm })
  }

  const { data: interactions } = await supabase
    .from('user_interactions')
    .select('item_id')
    .eq('user_id', userId)
    .limit(50)

  const interactedIds = interactions?.map((i) => i.item_id) ?? []
  const topItem = interactedIds[0]

  if (!topItem) {
    const { data: popular } = await supabase
      .from('items')
      .select('id, name, description, category')
      .limit(limit)
    return NextResponse.json({ items: popular, algorithm: 'popular' })
  }

  const { data: similar } = await supabase.rpc('similar_items', {
    target_item_id: topItem,
    exclude_ids: interactedIds,
    match_count: limit,
  })

  return NextResponse.json({
    items: similar,
    algorithm: 'content-based',
  })
}
```

## Common mistakes

- **Exposing OPENAI_API_KEY with a NEXT_PUBLIC_ prefix** — The OpenAI key is a secret that costs money per call. Exposing it in the browser allows anyone to make API calls on your account. Fix: Store OPENAI_API_KEY in the Vars tab without any prefix. Only call the OpenAI API from API routes and Server Actions, never from client components.
- **Not creating the IVFFlat index on the embedding column** — Without the index, pgvector performs a full table scan for every similarity search, which is prohibitively slow with more than a few thousand items. Fix: Create the index: CREATE INDEX ON items USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100). This enables sub-second search for up to 1 million items.
- **Computing recommendations on every page load** — Recommendation computation involves multiple database queries and potentially API calls. Running this on every request wastes resources and increases latency. Fix: Cache recommendations in the recommendations_cache table with a TTL (e.g., 1 hour). Check cache first and only recompute when expired or invalidated by new interactions.

## Best practices

- Use Edge Runtime for the recommendation endpoint to minimize latency for users worldwide
- Cache recommendations with a 1-hour TTL and invalidate when new interactions are tracked
- Store OPENAI_API_KEY and SUPABASE_SERVICE_ROLE_KEY in Vars tab without NEXT_PUBLIC_ prefix — server-only
- Use the IVFFlat index with vector_cosine_ops for sub-second similarity search at scale
- Implement a hybrid approach: collaborative filtering for active users, content-based for new users, popular items for anonymous visitors
- Use V0's prompt queuing to generate the embedding pipeline, recommendation API, and admin dashboard in sequence
- Track all interaction types (view/click/purchase/rating) to build a rich user preference profile

## Frequently asked questions

### What V0 plan do I need for a recommendations engine?

V0 Free works for the basic build, but Premium ($20/month) is recommended because the engine has multiple complex components (embedding pipeline, recommendation API, admin dashboard) that benefit from prompt queuing.

### How much does the OpenAI Embeddings API cost?

text-embedding-3-small costs $0.02 per 1 million tokens (about $0.00002 per item description). Embeddings only need to be generated once per item, making the cost negligible even for large catalogs.

### What is the cold-start problem and how is it solved?

New users have no interaction history, so collaborative filtering cannot work. The solution is a hybrid approach: fall back to content-based filtering (pgvector similarity on item embeddings) until the user has at least 5 interactions, then switch to collaborative filtering.

### How many items can pgvector handle?

With the IVFFlat index (lists=100), pgvector handles up to 1 million items with sub-second similarity search on Supabase free tier. For larger catalogs, increase the lists parameter and consider Supabase Pro for more memory.

### How do I deploy the recommendations engine?

Click Share then Publish to Production in V0. Set OPENAI_API_KEY and SUPABASE_SERVICE_ROLE_KEY in the Vars tab without NEXT_PUBLIC_ prefix. The recommendation endpoint uses Edge Runtime for global low-latency serving.

### Can RapidDev help build a custom recommendations engine?

Yes. RapidDev has built 600+ apps including ML-powered recommendation systems for e-commerce, content platforms, and SaaS products. Book a free consultation to discuss your specific recommendation needs.

---

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