Framer’s built-in search component matches substrings. A visitor types “sustainable sneakers”, your product is called “eco-friendly trainers”, the results panel is empty. The page loads fine, the visitor leaves, and nothing in your analytics tells you what happened.
Swapping that out for a semantic layer is a small amount of specific work. This is what it looks like on a Framer site.
Why substring search quietly loses sales
Substring match is cheap, instant, and only ever finds exactly the words the author happened to write. It cannot see synonyms. It cannot see translations. It cannot see intent. On a small marketing site that is fine, because people mostly navigate. On a site with any kind of catalogue – products, articles, case studies, talks – it starts to leak.
Semantic search fixes exactly that gap: it compares the meaning of the query to the meaning of each item, by turning both into vectors with an embeddings model. Cosine similarity between those vectors is your score. It does not replace keyword match – you still want “Model 3” to pin exactly – but it is what you reach for when the visitor speaks their own language, not yours.
The three pieces
You need three small things working together:
- An indexer that reads your Framer CMS, turns each item into an embedding, and stores it somewhere you can query.
- A search endpoint that takes a user query, embeds it, and returns the top matches by cosine similarity.
- A Code Component on your Framer site that talks to that endpoint and renders the results.
None of these are large. The whole thing lives in a few hundred lines.
Indexing the CMS
Framer exposes CMS collections as JSON at a public URL – the same one the site uses to render itself. You read it, feed each item’s text through an embeddings model, and store the resulting vector against the item’s id. Do this on a schedule, or on a webhook if your workflow has one.
// Runs on a cron (Cloudflare Worker, Vercel Cron, whatever).
const items = await fetchFramerCollection(COLLECTION_URL);
for (const item of items) {
const text = [item.title, item.excerpt, item.body]
.filter(Boolean)
.join('\n');
const vector = await embed(text); // one model call per item
await store.put(item.id, { vector, title: item.title, slug: item.slug });
}
Pick a small embeddings model – text-embedding-3-small is 1536 dimensions, costs almost nothing, and is more than accurate enough for a marketing-site catalogue. Store the vectors anywhere you can list and compare them: a single JSON file in R2, a Supabase table with pgvector, a KV namespace keyed by item id. For sites below a few thousand items the simplest thing – a flat array in memory – will outperform anything fancier.
The search endpoint
At query time you embed the incoming query with the same model, score it against every stored vector, sort, return the top few. Cosine similarity reduces to a dot product when both vectors are L2-normalised, which is the first micro-optimisation worth doing.
// GET /search?q=sustainable+sneakers
export async function handler(req) {
const q = new URL(req.url).searchParams.get('q')?.trim();
if (!q || q.length < 3) return json([]);
const qVec = await embed(q);
const index = await store.listAll(); // tiny catalogue, fine in memory
const scored = index
.map(({ id, vector, title, slug }) => ({
id, title, slug,
score: dot(qVec, vector),
}))
.filter(r => r.score > 0.35) // threshold - tune on your data
.sort((a, b) => b.score - a.score)
.slice(0, 8);
return json(scored);
}
The threshold is the one knob you will revisit. Too low and unrelated items start creeping in. Too high and the search feels empty. For a marketing catalogue, somewhere between 0.30 and 0.45 usually lands.
The Code Component
Framer lets you drop a React component onto the canvas. For search, you want a debounced input, an AbortController so stale responses do not overwrite fresh ones, and a small results panel that renders whatever shape you returned.
// SemanticSearch.tsx - drop into Framer as a Code Component
import { useEffect, useRef, useState } from 'react';
export default function SemanticSearch() {
const [q, setQ] = useState('');
const [results, setResults] = useState<Hit[]>([]);
const abort = useRef<AbortController | null>(null);
useEffect(() => {
if (q.trim().length < 3) { setResults([]); return; }
const t = setTimeout(async () => {
abort.current?.abort();
abort.current = new AbortController();
const r = await fetch(`${API}/search?q=${encodeURIComponent(q)}`, {
signal: abort.current.signal,
});
setResults(await r.json());
}, 220); // debounce - feels instant without hammering the endpoint
return () => clearTimeout(t);
}, [q]);
// ... input + results list ...
}
Two details that matter more than they look. Debounce at ~200ms: anything less and you pay for every keystroke, anything more and the search feels sluggish. And abort in-flight requests on every new keystroke, otherwise a slow response for “sus” will land after “sustainable sneakers” and briefly overwrite the correct results.
Where this breaks
Semantic search is not a free upgrade. Three things to be honest about.
Exact matches still matter. If someone types a SKU or a product name verbatim, you want that result pinned first, not ranked 0.78 behind something fuzzier. Keep a keyword pre-pass; only fall back to vectors when it returns nothing – or use both, and merge, with keyword matches on top.
Latency compounds. The embeddings call for the query is a network round-trip. If your endpoint lives on the other side of the planet, 150ms feels like a long time. Co-locate the endpoint with the visitor if you can; cache popular queries if you cannot.
CMS drift. Nothing is worse than semantic search that confidently returns an item that no longer exists. Your indexer has to run often enough that deletes and renames propagate – or the endpoint has to re-validate hits against the live collection before returning them.
The shortcut
The snippets above are the whole pattern – if you want to own it end to end, that is all you need. If you do not, I have packaged exactly the three pieces into AI Search for Framer, on the Framer Marketplace. You install it, point it at your CMS collection, and it handles the indexer, the embeddings endpoint, and the Code Component that renders the results. One threshold slider is the only knob, and it reindexes automatically when the CMS changes.
Either way, the thing worth internalising is the shape: embeddings turn “words your visitors use” and “words you wrote” into vectors in the same space, cosine similarity scores how close they are, and everything else is plumbing.



