Mastering Supabase pgvector for Semantic Search in Next.js
Implement AI-powered advanced semantic search in Next.js applications using Supabase pgvector, OpenAI embeddings, and Server Actions.
Introduction#
Search is a fundamental feature of any content-heavy SaaS or application, but traditional full-text keyword search (ILIKE '%search_term%') feels archaic in the AI era. Users don't use exact terminology—they search by context, concept, and intent.
Using Supabase pgvector in combination with OpenAI embeddings and Next.js, we can build a semantic search engine that understands meaning. If a user searches for "billing issues", the engine will surface documents about "credit card declines" or "invoice errors"—even if the literal words are completely different.
1. Preparing the Supabase Database#
First, we need to enable the vector extension in Postgres and create a table designed to store our application's text alongside high-dimensional embedding arrays.
In your Supabase SQL editor (or via local migrations), run:
-- Enable the pgvector extension to work with embedding vectors
CREATE EXTENSION IF NOT EXISTS vector;
-- Create our knowledge base table
CREATE TABLE public.documents (
id uuid primary key default gen_random_uuid(),
content text not null,
metadata jsonb,
-- 1536 is the correct dimensionality for OpenAI's text-embedding-3-small
embedding vector(1536)
);
-- Create an HNSW index for blazing fast vector similarity search
CREATE INDEX ON public.documents USING hnsw (embedding vector_cosine_ops);
Writing the Match Function#
Supabase exposes SQL functions directly to your frontend via RPC (Remote Procedure Call). We need a function that takes a search embedding array and compares it to our documents table using cosine distance (<=>).
CREATE OR REPLACE FUNCTION match_documents (
query_embedding vector(1536),
match_threshold float,
match_count int
)
RETURNS TABLE (
id uuid,
content text,
similarity float
)
LANGUAGE sql STABLE
AS $$
SELECT
documents.id,
documents.content,
1 - (documents.embedding <=> query_embedding) AS similarity
FROM documents
-- Filter by threshold to only return relevant matches
WHERE 1 - (documents.embedding <=> query_embedding) > match_threshold
ORDER BY documents.embedding <=> query_embedding
LIMIT match_count;
$$;
Why HNSW? The hnsw index is vastly superior to the older ivfflat index. HNSW provides faster query times and doesn't require rebuilding the index as your dataset scales. It consumes slightly higher RAM, but the performance payoff in production Next.js apps is unarguable.
2. Generating Embeddings via Next.js Server Actions#
To insert searchable data, we must take plain text (e.g., an article, a user profile) and convert it into a vector array via OpenAI, then store it in Supabase. We do this securely server-side.
Install the required SDK: npm install openai @supabase/ssr
// app/actions/embeddings.ts
'use server'
import OpenAI from 'openai'
import { createClient } from '@/lib/supabase/server'
const openai = new OpenAI()
export async function ingestDocument(content: string) {
const supabase = createClient()
// 1. Generate the embedding from OpenAI
const response = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: content.trim(),
})
const embedding = response.data[0].embedding
// 2. Insert into Supabase
const { error } = await supabase.from('documents').insert({
content,
embedding
})
if (error) {
throw new Error('Failed to insert vector into database')
}
return { success: true }
}
3. The Search Experience Implementation#
Now, we build the actual search implementation. When the user submits a search string, we process it into an embedding vector, then call the match_documents SQL function we defined earlier.
// app/actions/search.ts
'use server'
import OpenAI from 'openai'
import { createClient } from '@/lib/supabase/server'
export async function performSemanticSearch(query: string) {
const openai = new OpenAI()
const supabase = createClient()
// Turn user text query into an embedding
const embeddingResponse = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: query,
})
const queryEmbedding = embeddingResponse.data[0].embedding
// Call the Postgres RPC function
const { data: matchedDocs, error } = await supabase.rpc('match_documents', {
query_embedding: queryEmbedding,
match_threshold: 0.70, // Adjust depending on strictness needs
match_count: 5 // Return top 5 results
})
if (error) throw new Error(error.message)
return matchedDocs
}
The Search UI#
Using Next.js Client Components and useTransition, we can craft a beautiful, responsive search interface.
// components/SemanticSearchbar.tsx
'use client'
import { useState, useTransition } from 'react'
import { performSemanticSearch } from '@/app/actions/search'
export function SemanticSearchbar() {
const [query, setQuery] = useState('')
const [results, setResults] = useState<any[]>([])
const [isPending, startTransition] = useTransition()
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
startTransition(async () => {
const docs = await performSemanticSearch(query)
setResults(docs)
})
}
return (
<div className="max-w-xl mx-auto space-y-4">
<form onSubmit={handleSearch} className="flex gap-2">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by meaning or concept..."
className="w-full p-2 border rounded-md"
/>
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-black text-white rounded-md"
>
{isPending ? 'Searching...' : 'Search'}
</button>
</form>
<ul className="grid gap-2">
{results.map((r, i) => (
<li key={i} className="p-4 border shadow-sm rounded-md">
<p className="text-sm text-gray-800">{r.content}</p>
<span className="text-xs text-green-600 block mt-2">
{(r.similarity * 100).toFixed(1)}% Match
</span>
</li>
))}
</ul>
</div>
)
}
Considerations for Scale#
- Chunking: Don't embed entire 5,000-word articles as a single vector. Split them into 300-500 token chunks. When a chunk is matched, return the parent document.
- Metadata Filtering: Combine semantic search with metadata matching. If a user only wants "Active" projects, add traditional SQL filtering to your RPC function explicitly alongside cosine distance.
Key Takeaways#
- Vector math solves strict lexical issues: Enable pgvector and build HNSW indexes to allow your Next.js application to "understand" search intent.
- Server Actions streamline embeddings: Keep the OpenAI SDK server-side and trigger it directly from forms using Next 14+ Server Actions.
- RPC solves complex SQL in Supabase: Utilize the RPC method to invoke complex mathematical similarity matching securely.
Next Steps#
To push your Next.js application's boundaries even further into AI territory, check out our comprehensive guide on Architecting Next.js Servers for AI Integration where we discuss streaming architectures and agentic UI interfaces.
Related Debugging Notes#
- Next.js + Supabase AI and search hub
- Production RAG with Supabase pgvector and Next.js
- Mastering Supabase Edge Functions with Next.js
- Machine Learning Basics for JavaScript Developers
Production Notes#
- Root cause to verify: separate retrieval quality, model prompting, latency, and cost before changing the whole pipeline.
- Production fix pattern: log retrieved chunks, model names, token usage, and eval results for every important RAG change.
- Verification step: run a small golden set before and after the change to catch recall regressions.
One email a month — no fluff
RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.
Related Guides
Production RAG with Supabase pgvector and Next.js
Build a production-grade RAG app with Supabase pgvector, Next.js App Router, hybrid search, reranking, streaming responses, evals, and cost tracking.
AI Integration for Next.js + Supabase Applications
Complete guide to integrating AI capabilities into Next.js and Supabase applications. Learn OpenAI integration, chat interfaces, vector search, RAG systems,...
Mastering Supabase Edge Functions with Next.js
Complete guide to building and deploying Supabase Edge Functions with Next.js. Learn serverless functions, Deno runtime, database triggers, webhooks, scheduled jobs, and real-world use cases.