Most image search tutorials build a search bar. Type "red sneaker", get red sneakers. That's search.
Pinterest isn't search. Pinterest is discovery. You open the app, scroll a masonry grid of images, tap one that catches your eye, and suddenly your whole feed shifts toward that taste. No keywords typed. No search button pressed. The system figures out what you like from what you click and serves more of it.
This tutorial builds that. A Pinterest-style discovery feed where the grid reacts in real-time to what the user engages with, using visual similarity under the hood.
What We're Building
- A masonry grid feed with infinite scroll
- A React component that loads images from your backend
- A backend that tracks clicks and biases future results toward what the user engaged with
- Visual similarity search powered by Vecstore
The key difference from a normal search: the feed has no query box. It just learns.
Prerequisites
- Node.js 18+
- A Vecstore account (free tier works)
- An image database seeded with a few hundred images
- Basic React knowledge
How the Discovery Loop Works
Before the code, the mental model. Pinterest's "related pins" isn't magic. It's three things stacked together:
- Visual embeddings — every image is a vector in a shared space
- User signal — when a user taps an image, that's a signal they like that style
- Feed bias — the next batch of images is weighted toward things similar to what they tapped
So the loop is: show images → track which ones get tapped → use tapped images as "query" for the next batch → repeat.
The more the user interacts, the more the feed converges on their taste. No explicit search, no categories, no tags.
Step 1: Build the Backend
Create server.js. This handles initial feed, related images, and tap tracking.
import express from 'express';
import cors from 'cors';
const app = express();
app.use(cors());
app.use(express.json());
const API_KEY = process.env.VECSTORE_API_KEY;
const DB_ID = process.env.VECSTORE_DB_ID;
const BASE = 'https://api.vecstore.app/api';
const HEADERS = {
'X-API-Key': API_KEY,
'Content-Type': 'application/json',
};
// naive in-memory session store. use Redis in production.
const sessions = new Map();
// GET /feed?session=xxx&cursor=0 - return next batch
app.get('/feed', async (req, res) => {
const { session, cursor = 0 } = req.query;
const state = sessions.get(session) || { tapped: [], seen: new Set() };
let results = [];
if (state.tapped.length === 0) {
// cold start - random popular items
results = await getColdStart(parseInt(cursor));
} else {
// bias toward recently tapped images
results = await getBiasedFeed(state.tapped, state.seen);
}
// mark as seen so we don't show again
results.forEach(r => state.seen.add(r.vector_id));
sessions.set(session, state);
res.json({
results,
nextCursor: parseInt(cursor) + results.length,
});
});
// POST /tap - user interacted with an image
app.post('/tap', (req, res) => {
const { session, vector_id, image_url } = req.body;
const state = sessions.get(session) || { tapped: [], seen: new Set() };
// keep last 5 taps as query signal
state.tapped = [{ vector_id, image_url }, ...state.tapped].slice(0, 5);
sessions.set(session, state);
res.json({ ok: true });
});
// GET /similar/:id - full-page "related" view
app.get('/similar/:id', async (req, res) => {
const result = await fetch(`${BASE}/databases/${DB_ID}/search`, {
method: 'POST',
headers: HEADERS,
body: JSON.stringify({ vector_id: req.params.id, top_k: 30 }),
});
res.json(await result.json());
});
app.listen(3001, () => console.log('Running on 3001'));
Step 2: Cold Start and Biased Feed Logic
Two helper functions do the real work.
// Cold start - no user signal yet. Use random seed queries
// to surface variety. In production, replace with your
// trending/popular items.
const COLD_START_QUERIES = [
'minimalist home decor',
'vintage fashion',
'modern architecture',
'nature photography',
'food styling',
'street art',
];
async function getColdStart(cursor) {
const query = COLD_START_QUERIES[
Math.floor(Math.random() * COLD_START_QUERIES.length)
];
const result = await fetch(`${BASE}/databases/${DB_ID}/search`, {
method: 'POST',
headers: HEADERS,
body: JSON.stringify({ query, top_k: 20 }),
});
const data = await result.json();
return data.results || [];
}
// Biased feed - take last few tapped images and search
// for similar ones. Mix results so feed doesn't converge too fast.
async function getBiasedFeed(tapped, seen) {
// pick one tapped image at random, weighted toward recent
const weighted = tapped.flatMap((t, i) =>
Array(tapped.length - i).fill(t)
);
const pick = weighted[Math.floor(Math.random() * weighted.length)];
const result = await fetch(`${BASE}/databases/${DB_ID}/search`, {
method: 'POST',
headers: HEADERS,
body: JSON.stringify({
image_url: pick.image_url,
top_k: 30,
}),
});
const data = await result.json();
// filter out already-seen items and cap to 20
return (data.results || [])
.filter(r => !seen.has(r.vector_id))
.slice(0, 20);
}
Two things worth noting:
Weighted recency. Recent taps matter more than old ones. The weighted array duplicates newer items so random picks lean toward recent interactions.
Seen filtering. Without this, the feed loops. User taps a blue chair, sees similar chairs, taps one, sees the same chairs again. Tracking seen IDs per session keeps the feed fresh.
Step 3: Build the Masonry Grid
Install the frontend dependencies:
npm install masonic
masonic handles the tricky part — virtualized masonry layout with variable heights. It renders tens of thousands of cells without lag.
Create DiscoveryFeed.jsx:
import { useState, useEffect, useCallback } from 'react';
import { Masonry } from 'masonic';
const API = 'http://localhost:3001';
// persistent session id - simple random string in localStorage
function getSessionId() {
let id = localStorage.getItem('vs-session');
if (!id) {
id = Math.random().toString(36).slice(2);
localStorage.setItem('vs-session', id);
}
return id;
}
export default function DiscoveryFeed() {
const [items, setItems] = useState([]);
const [cursor, setCursor] = useState(0);
const [loading, setLoading] = useState(false);
const session = getSessionId();
const loadMore = useCallback(async () => {
if (loading) return;
setLoading(true);
const res = await fetch(
`${API}/feed?session=${session}&cursor=${cursor}`
);
const data = await res.json();
setItems(prev => [...prev, ...data.results]);
setCursor(data.nextCursor);
setLoading(false);
}, [cursor, loading, session]);
useEffect(() => {
loadMore();
}, []);
const handleTap = async (item) => {
await fetch(`${API}/tap`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session,
vector_id: item.vector_id,
image_url: item.metadata.image_url,
}),
});
// after tap, prepend fresh batch to feed
const res = await fetch(
`${API}/feed?session=${session}&cursor=${cursor}`
);
const data = await res.json();
setItems(prev => [...data.results, ...prev]);
setCursor(data.nextCursor);
};
return (
<Masonry
items={items}
columnGutter={12}
columnWidth={240}
overscanBy={5}
onRender={(startIdx, stopIdx, items) => {
// trigger load when near bottom
if (stopIdx >= items.length - 10) loadMore();
}}
render={({ data }) => (
<div onClick={() => handleTap(data)}
style={{ cursor: 'pointer' }}>
<img
src={data.metadata.image_url}
alt=""
style={{
width: '100%',
display: 'block',
borderRadius: 8,
}}
loading="lazy"
/>
</div>
)}
/>
);
}
Three things happening:
Masonryhandles layout and virtualization. Columns auto-size tocolumnWidth={240}.onRenderfires as rows mount. When user is within 10 items of the bottom, load the next batch.handleTapsends the tap to the backend, then refetches so the feed reacts immediately.
That last part is the magic. The user taps an image → backend records it → next fetch biases toward similar images → feed shifts in real-time.
Step 4: Add the Related Modal
Pinterest also has a "related" view when you click into a pin. Full-page view of the pin with similar images below. Here's a minimal version.
import { useState } from 'react';
export function PinModal({ item, onClose }) {
const [related, setRelated] = useState([]);
useEffect(() => {
if (!item) return;
fetch(`${API}/similar/${item.vector_id}`)
.then(r => r.json())
.then(data => setRelated(data.results || []));
}, [item]);
if (!item) return null;
return (
<div onClick={onClose}
style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.85)',
overflow: 'auto', padding: 24, zIndex: 1000,
}}>
<div onClick={e => e.stopPropagation()}
style={{ maxWidth: 900, margin: '0 auto' }}>
<img src={item.metadata.image_url}
style={{ width: '100%', borderRadius: 12 }} />
<h3 style={{ color: 'white', marginTop: 24 }}>More like this</h3>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill,minmax(180px,1fr))',
gap: 12,
}}>
{related.map(r => (
<img key={r.vector_id}
src={r.metadata.image_url}
style={{ width: '100%',
aspectRatio: 1,
objectFit: 'cover',
borderRadius: 8 }} />
))}
</div>
</div>
</div>
);
}
Wire it into DiscoveryFeed by setting a selectedItem state on tap and rendering <PinModal item={selectedItem} onClose={...} /> at the bottom.
Tuning the Discovery Loop
The basic version works. But a few knobs matter for the feel of the feed.
Tap window. Right now the backend keeps the last 5 taps as signal. Too few and the feed over-reacts to any single tap. Too many and it never adapts. 5-10 is a good range.
Recency weight. The weighted picker biases toward recent taps. You can strengthen this by using exponential weights (Math.pow(2, tapped.length - i - 1)) instead of linear.
Variety injection. Pure similarity makes the feed claustrophobic. Every 5th or 6th item, inject something random from cold-start queries. Breaks the filter bubble and surfaces new things the user might like.
async function getBiasedFeed(tapped, seen) {
const similar = await fetchSimilar(tapped, seen, 16);
const variety = await getColdStart(0);
// interleave - similar, similar, similar, variety, repeat
const mixed = [];
let vi = 0;
for (let i = 0; i < similar.length; i++) {
mixed.push(similar[i]);
if ((i + 1) % 4 === 0 && variety[vi]) {
mixed.push(variety[vi++]);
}
}
return mixed;
}
Negative signal. Pinterest uses "not interested" buttons. You can track skipped items (scrolled past without tapping) and down-weight similar images. This requires more instrumentation but drastically improves feed quality over time.
Things to Keep in Mind
Session state in production. The in-memory Map works for a demo. For real users, use Redis or a persistent store keyed to user ID. Taps should survive across devices.
Image loading performance. Masonry grids are image-heavy. Use loading="lazy" on every image. Serve multiple sizes and let the browser pick with srcSet. For production, put images on a CDN with automatic format conversion (WebP, AVIF).
Vecstore vector_id search. The /similar/:id endpoint uses vector_id instead of image_url. This is faster because Vecstore doesn't need to re-embed — it already has the vector. Use this whenever you have the ID.
Cost management. Every scroll triggers API calls. For a popular feed, that's a lot of queries. Cache the first few cold-start batches (they're the same for new users). Cache related results per vector ID. The traffic goes from "one API call per scroll" to "one API call per tap" fast.
Cold start is a design problem. The six cold-start queries above are placeholders. For a real product, replace them with trending items, editorial picks, or a curated onboarding set. The first 30 seconds of a new user's session determine whether they stick.
What Else You Can Do
The same discovery pattern works for:
- Product discovery on an e-commerce store (what we covered in find similar products but as a whole feed instead of a sidebar)
- Dating apps where "taps" are swipe rights and the feed learns your type
- Real estate browsing where clicking a property biases toward similar listings
- Recipe discovery where engagement shapes the next batch toward your taste
It's the same loop. Images in, visual embeddings out, engagement signal drives the next batch.
Wrapping Up
Full setup: an Express backend with three routes, a React component with a masonry grid, and session-based tap tracking. The discovery loop is fewer than 200 lines of code.
The thing that makes this feel like Pinterest isn't the layout. It's the feedback loop. Every tap reshapes the next batch. Users don't realize they're training the feed — they just feel like the app "gets them." That's what keeps them scrolling.
Get started with Vecstore - free tier includes enough credits to build and test a discovery feed.


