Semantic Search with OpenAI Embeddings and Upstash Vector in Next.js

August 2, 2025⏱️ 5 min read

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.

Semantic Search with OpenAI Embeddings and Upstash Vector in Next.js

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.