Learn how to build a scalable recommendations engine with Lovable using practical steps, code examples, evaluation tips, and deployment best practices

Book a call with an Expert
Starting a new venture? Need to upgrade your web app? RapidDev builds application with your growth in mind.
Build a simple, production-practical recommendations engine in Lovable by keeping the recommendation logic server-side (an API route in your app) that queries Supabase (items + interactions tables), using Lovable Cloud Secrets for the Supabase keys, and adding a small client component that calls that API. You’ll do everything through Lovable Chat Mode edits, Preview, and Publish — no terminal. Use Supabase SQL editor (outside Lovable) to create tables; keep keys in Lovable Secrets and never paste them into chat.
We’ll add: a server API endpoint /api/recommendations that reads a user’s interactions from Supabase, computes simple content-based recommendations by tag overlap, and returns ranked items. A client hook and UI component will call that endpoint and render recommendations. Interactions are recorded via a separate /api/interaction route. Supabase tables (items, interactions) must be created in the Supabase dashboard (SQL provided).
Paste each prompt into Lovable chat one at a time. Each prompt tells Lovable exactly which files to create or update.
Prompt 1 — Add server recommendation API
Goal: Create /api/recommendations server route that returns ranked recommendations for a given user\_id.
Files to create: src/pages/api/recommendations.ts
Acceptance criteria (done when):
Secrets/Integration: Requires SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in Lovable Cloud Secrets (see next prompt).
Code to create (paste into Lovable so it can write the file):
// create file src/pages/api/recommendations.ts
import type { NextApiRequest, NextApiResponse } from 'next'
// Helper: fetch Supabase REST
async function supabaseFetch(path: string, opts: any) {
const url = `${process.env.SUPABASE_URL}/rest/v1/${path}`
const res = await fetch(url, {
...opts,
headers: {
'apikey': process.env.SUPABASE_SERVICE_ROLE_KEY as string,
'Authorization': `Bearer ${process.env.SUPABASE_SERVICE_ROLE_KEY}`,
'Content-Type': 'application/json',
...opts?.headers
}
})
return res
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') return res.status(405).end()
const { user_id, limit = 10 } = req.body
if (!user_id) return res.status(400).json({ error: 'user_id required' })
// 1) fetch interactions
const intRes = await supabaseFetch(`interactions?user_id=eq.${user_id}`, { method: 'GET' })
const interactions = await intRes.json()
const interactedIds = interactions.map((i: any) => i.item_id)
// 2) fetch interacted items to collect tags
const tagSet = new Set<string>()
if (interactedIds.length) {
const ids = interactedIds.join(',')
const itemsRes = await supabaseFetch(`items?id=in.(${ids})`, { method: 'GET' })
const items = await itemsRes.json()
items.forEach((it: any) => (it.tags || []).forEach((t: string) => tagSet.add(t)))
}
// 3) if no tags, fallback to latest items
if (tagSet.size === 0) {
const latest = await supabaseFetch(`items?select=*&order=created_at.desc&limit=${limit}`, { method: 'GET' })
return res.status(200).json({ items: await latest.json() })
}
// 4) fetch candidate items that share any tag
const tagsArray = Array.from(tagSet).map(t => encodeURIComponent(`{${t}}`))
// PostgREST text[] filters usage: use cs=contains; using simple contains via LIKE fallback
const candidatesRes = await supabaseFetch(`items?select=*&limit=100`, { method: 'GET' })
const candidates = await candidatesRes.json()
// 5) score by overlap
function scoreItem(it: any) {
const tags = it.tags || []
let score = 0
tags.forEach((t: string) => { if (tagSet.has(t)) score++ })
return score
}
const scored = candidates
.filter((it: any) => !interactedIds.includes(it.id))
.map((it: any) => ({ ...it, score: scoreItem(it) }))
.sort((a: any, b: any) => b.score - a.score)
.slice(0, limit)
return res.status(200).json({ items: scored })
}
Prompt 2 — Add interaction recording API
Goal: Create /api/interaction to record user interactions.
Files to create: src/pages/api/interaction.ts
Acceptance criteria:
Code to create:
// create file src/pages/api/interaction.ts
import type { NextApiRequest, NextApiResponse } from 'next'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') return res.status(405).end()
const { user_id, item_id } = req.body
if (!user_id || !item_id) return res.status(400).json({ error: 'user_id and item_id required' })
const url = `${process.env.SUPABASE_URL}/rest/v1/interactions`
const insertRes = await fetch(url, {
method: 'POST',
headers: {
'apikey': process.env.SUPABASE_SERVICE_ROLE_KEY as string,
'Authorization': `Bearer ${process.env.SUPABASE_SERVICE_ROLE_KEY}`,
'Content-Type': 'application/json',
'Prefer': 'return=representation'
},
body: JSON.stringify({ user_id, item_id })
})
const payload = await insertRes.json()
return res.status(insertRes.ok ? 200 : 500).json(payload)
}
Prompt 3 — Add client UI and hook
Goal: Create a Recommendations component that calls /api/recommendations and renders items; add a TrackInteraction helper to call /api/interaction.
Files to create/modify: create src/components/Recommendations.tsx and modify src/App.tsx to show
Acceptance criteria:
Code snippets to create/modify:
// create file src/components/Recommendations.tsx
import React, { useEffect, useState } from 'react'
export default function Recommendations({ userId }: { userId: string }) {
const [items, setItems] = useState<any[]>([])
useEffect(() => {
if (!userId) return
fetch('/api/recommendations', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ user_id: userId, limit: 6 }) })
.then(r => r.json()).then(j => setItems(j.items || []))
}, [userId])
function track(itemId: string) {
fetch('/api/interaction', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ user_id: userId, item_id: itemId }) })
}
return (
<div>
<h3>Recommendations</h3>
<ul>
{items.map(it => <li key={it.id}>
<button onClick={() => track(it.id)}>{it.title}</button>
</li>)}
</ul>
</div>
)
}
// update src/App.tsx — add import and render for preview
import Recommendations from './components/Recommendations'
// inside App render area:
<Recommendations userId="demo-user-1" />
Run this SQL in the Supabase SQL editor (outside Lovable):
// create tables
create table items (
id uuid primary key default gen_random_uuid(),
title text,
description text,
tags text[],
created_at timestamptz default now()
);
create table interactions (
id uuid primary key default gen_random_uuid(),
user_id text,
item_id uuid references items(id),
created_at timestamptz default now()
);
This prompt helps an AI assistant understand your setup and guide to build the feature
This prompt helps an AI assistant understand your setup and guide to build the feature
This prompt helps an AI assistant understand your setup and guide to build the feature

Book a call with an Expert
Starting a new venture? Need to upgrade your web app? RapidDev builds application with your growth in mind.
Use embeddings + metadata filters for retrieval, generate candidate recommendations with a small/cheap model, personalize with user/item embeddings, run offline batch pipelines for indexing, and keep secrets & deployments inside Lovable (Secrets UI, Preview, Publish, GitHub sync) while doing heavy lifting (migrations, long jobs) via external CI or hosted workers.
The code below shows generating an OpenAI embedding and doing a semantic similarity search across a candidate subset fetched from Supabase (safe when you can’t run server-side vector SQL in Lovable). Store keys via Lovable Secrets and use Preview to test.
// // Node/Express route example
import express from 'express'
import OpenAI from 'openai'
import { createClient } from '@supabase/supabase-js'
const app = express()
app.use(express.json())
// // Secrets loaded by Lovable into env: process.env.OPENAI_KEY, SUPABASE_URL, SUPABASE_KEY
const openai = new OpenAI({ apiKey: process.env.OPENAI_KEY })
const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_KEY)
// // cosine similarity helper
function cosine(a, b) {
let dot = 0, na = 0, nb = 0
for (let i=0;i<a.length;i++){ dot += a[i]*b[i]; na += a[i]*a[i]; nb += b[i]*b[i] }
return dot / (Math.sqrt(na)*Math.sqrt(nb) + 1e-10)
}
app.post('/recommend', async (req, res) => {
const { userId, textQuery, category } = req.body
// // embed query
const embResp = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: textQuery
})
const qVec = embResp.data[0].embedding
// // fetch candidates by metadata (limit to N to avoid fetching whole table)
const { data: items } = await supabase
.from('items')
.select('id,title,description,embedding')
.ilike('category', category ?? '%')
.limit(200)
// // compute similarities in JS
const scored = items.map(it => ({ ...it, score: cosine(qVec, it.embedding) }))
.sort((a,b)=>b.score - a.score)
.slice(0, 10)
return res.json({ results: scored })
})
app.listen(3000)
From startups to enterprises and everything in between, see for yourself our incredible impact.
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.