Semantic Search with OpenAI Embeddings and Upstash Vector in Next.js
A complete walkthrough for building semantic search in a modern Next.js App Router project using OpenAI’s text-embedding-3-small model and Upstash Vector.
This article guides you from generating vector embeddings to displaying smart search results — all without a traditional backend.
Why Semantic Search?
Traditional search systems rely on matching exact keywords. But real users often phrase things differently than the content they're looking for.
Semantic search solves this by understanding meaning — not just matching words.
By embedding both your content and the user’s query into vectors, you can compare their similarity using cosine distance in a vector database like Upstash Vector.
This results in much smarter, more intuitive search results.
1. Setup: Next.js + Required Dependencies
Ensure your project uses Next.js App Router and install the required packages:
pnpm add openai @upstash/vector zod dotenv
Then, set your environment variables:
OPENAI_API_KEY=sk-...
UPSTASH_VECTOR_REST_URL=https://...
UPSTASH_VECTOR_REST_TOKEN=...
These will be used to index your content and search in real-time.
2. Indexing MDX Content into Upstash Vector
Instead of hardcoding your content, you can dynamically read your articles and projects from your MDX files using utility functions like
getAllArticles()
and getAllProjects()
from lib/articles.ts
and lib/projects.ts
.
We then embed them and upsert into Upstash Vector using a one-time script.
Example: scripts/generate-embedding.ts
import { OpenAI } from 'openai'
import { Index } from '@upstash/vector'
import { getAllArticles } from '@/lib/articles'
import { getAllProjects } from '@/lib/projects'
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! })
const index = Index.fromEnv()
async function run() {
const items = [...getAllArticles(), ...getAllProjects()]
for (const item of items) {
const input = `${item.title} ${item.description ?? ''}`
const res = await openai.embeddings.create({
input,
model: 'text-embedding-3-small',
})
const vector = res.data[0].embedding
await index.upsert([
{
id: item.slug,
vector,
metadata: {
title: item.title,
slug: item.slug,
type: item.category ? 'articles' : 'projects',
},
},
])
}
}
run()
💡 You only need to run this when new content is added.
3. Real-Time Search with /api/search/query
When a user types in the search bar, you generate the query’s embedding and search it against the vectors in Upstash. Here’s your production-ready implementation:
// src/app/api/search/query/route.ts
import { OpenAI } from 'openai'
import { NextResponse } from 'next/server'
import { Index } from '@upstash/vector'
import { config } from 'dotenv'
config()
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! })
const index = Index.fromEnv()
export async function POST(req: Request) {
const { query } = await req.json()
if (!query) {
return NextResponse.json({ error: 'Query is required' }, { status: 400 })
}
const embeddingResponse = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: query,
})
const embedding = embeddingResponse.data[0].embedding
const search = await index.query({
vector: embedding,
topK: 5,
includeMetadata: true,
})
const results = search.map((item) => {
const { slug, type, title } = item.metadata as {
slug: string
type: 'articles' | 'projects'
title: string
}
return {
id: item.id,
score: item.score,
title,
type,
slug,
const url = `/${type}/${slug}` // Adjust this to match your routing structure
}
})
return NextResponse.json({ results })
}
This endpoint is fast, accurate, and cost-efficient. No extra route like /api/search/embed
is needed.
4. Connecting to Your SearchDialog
In your SearchDialog.tsx
, call the /api/search/query
endpoint as users type:
const response = await fetch('/api/search/query', {
method: 'POST',
body: JSON.stringify({ query }),
})
const { results } = await response.json()
Use debounce to limit requests:
useEffect(() => {
const timeout = setTimeout(() => {
if (query.length >= 2) {
search()
}
}, 150)
return () => clearTimeout(timeout)
}, [query])
Render the results using your existing <ArticleCard />
and <ProjectCard />
.
5. Example SearchDialog Component
Here’s a simplified example of a SearchDialog
component that:
- Captures user input
- Debounces the request
- Sends the query to
/api/search/query
- Renders a list of semantic search results
'use client'
import { useEffect, useState } from 'react'
export default function SearchDialog() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
useEffect(() => {
const timeout = setTimeout(() => {
if (query.length >= 2) {
fetch('/api/search/query', {
method: 'POST',
body: JSON.stringify({ query }),
})
.then((res) => res.json())
.then((data) => setResults(data.results))
}
}, 150)
return () => clearTimeout(timeout)
}, [query])
return (
<div className="space-y-4">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search articles or projects..."
className="w-full rounded border px-4 py-2 text-sm"
/>
<ul className="space-y-2">
{results.map((r: any) => (
<li key={r.id} className="text-sm">
<a href={r.url} className="text-blue-600 hover:underline">
{r.title}
</a>
<span className="ml-2 text-xs text-zinc-500">({r.type})</span>
</li>
))}
</ul>
</div>
)
}
💡 This is a minimal example. You can replace the result list with custom cards like
<ArticleCard />
or<ProjectCard />
to match your site’s design.
6. Extra Tips
- Only show results with score > 0.7 for relevance.
- Add a loading state to prevent flicker.
- Upstash Vector is serverless and fast — no need for Elasticsearch.
- Keep OpenAI usage low by caching embeddings if needed.
Conclusion
By combining OpenAI’s embeddings with Upstash Vector, you can build a fully serverless semantic search experience — perfectly integrated with your MDX-based content system.
This solution is clean, cost-effective, and scalable for any content-rich Next.js application.
Now your search bar isn’t just smart — it’s meaningful.