Add AI Search to a Next.js Site with Embeddings (No Paid Vector DB)
Semantic search that understands meaning, built with free local embeddings in Transformers.js, an in-memory index or MongoDB Atlas Vector Search, and a Next.js Route Handler.

Mohit Malaya Nandy
Software Engineer & Product Builder

On this page
Keyword search fails the moment a reader uses different words from yours. Someone searches for "keep users logged in safely", your post is titled "Rotating Refresh Tokens", and they find nothing. Semantic search fixes this by matching meaning, not letters.
In this guide you will add AI-powered search to a Next.js site using embeddings. You will not need a paid AI API or a paid vector database. The model runs for free inside your own server, and you can choose between a simple in-memory index or MongoDB Atlas Vector Search when your content grows. The embedding and search code below was run and tested.
How AI search works#
An embedding model turns text into a list of numbers, called a vector. Texts that mean similar things end up with similar vectors. Search then becomes a simple idea: turn the query into a vector and find the documents whose vectors are closest.
- Index once: embed every post and store the vectors.
- Search on demand: embed the user's query with the same model.
- Compare: score each document by cosine similarity and return the best matches.
We will use all-MiniLM-L6-v2, a small, popular open model that produces 384-number vectors, through Transformers.js, which runs models directly in Node.js.
| Option | Best for | Cost | Extra setup |
|---|---|---|---|
| A. In-memory JSON index | Blogs and docs up to a few thousand items | Free | None |
| B. MongoDB Atlas Vector Search | Large or frequently changing content, filters | Free tier available | Atlas cluster and an index |
Step 1: Install Transformers.js#
npm install @huggingface/transformersTransformers.js uses a native ONNX runtime on the server, so tell Next.js not to bundle it. Add it to serverExternalPackages:
/** @type {import('next').NextConfig} */
const nextConfig = {
serverExternalPackages: ['@huggingface/transformers'],
};
export default nextConfig;Step 2: Write the embedding helper#
import { pipeline } from '@huggingface/transformers';
// Load the model once per server process; the first call downloads it (~25 MB).
let extractorPromise;
function getExtractor() {
extractorPromise ??= pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2', { dtype: 'q8' });
return extractorPromise;
}
export async function embed(text) {
const extractor = await getExtractor();
const output = await extractor(text, { pooling: 'mean', normalize: true });
return Array.from(output.data); // 384 numbers
}pooling: 'mean'averages the per-word vectors into one vector for the whole text.normalize: truescales every vector to length 1. That lets you compare them with a fast dot product, which then equals cosine similarity.dtype: 'q8'uses a compressed version of the model, which is smaller and faster with almost no loss in quality.
Option A: In-memory search (no database)#
Build the index
Write a script that embeds your posts once and saves the vectors to a JSON file. Here the posts come from a JSON file; in a real site, read them from your CMS, Markdown files or metadata.
import { readFile, writeFile } from 'node:fs/promises';
import { embed } from '../lib/embed.js';
const posts = JSON.parse(await readFile('content/posts.json', 'utf8'));
const index = [];
for (const post of posts) {
// Embed the title and a summary: the parts that best describe the post.
const vector = await embed(`${post.title}\n\n${post.text}`);
index.push({ slug: post.slug, title: post.title, vector });
console.log('embedded', post.slug);
}
await writeFile('lib/search-index.json', JSON.stringify(index));Run it with node scripts/build-index.mjs, and add it to your build so the index stays fresh: "build": "node scripts/build-index.mjs && next build".
Write the search function
import { readFile } from 'node:fs/promises';
import { embed } from './embed.js';
const index = JSON.parse(await readFile(new URL('./search-index.json', import.meta.url), 'utf8'));
// Vectors are normalized, so the dot product equals cosine similarity.
const dot = (a, b) => a.reduce((sum, x, i) => sum + x * b[i], 0);
export async function search(query, limit = 3, minScore = 0.25) {
const q = await embed(query);
return index
.map(({ slug, title, vector }) => ({ slug, title, score: dot(q, vector) }))
.filter((r) => r.score >= minScore)
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}I tested it against four posts. Notice how few of the query words appear in the matching titles:
| Query | Top result | Score |
|---|---|---|
| how do I keep users logged in safely | Secure Admin Authentication in MERN | 0.41 |
| python beginner web app | How to Create a Flask App | 0.55 |
| morning tea walk | Bengaluru, Before and After the Rush | 0.37 |
| make a short film with AI | Directing the AI (storyboarding) | 0.67 |
| kubernetes autoscaling | No results (below the 0.25 threshold) | – |
Step 3: Add the search API route#
Expose search through a Route Handler. It must run on the Node.js runtime, because the model needs Node APIs:
import { search } from '@/lib/search';
export const runtime = 'nodejs';
export async function GET(request) {
const q = new URL(request.url).searchParams.get('q')?.trim() ?? '';
if (q.length < 3 || q.length > 200) {
return Response.json({ results: [] });
}
const results = await search(q);
return Response.json({ results });
}The length check keeps empty or huge queries from wasting work. Test it at /api/search?q=python beginner.
Step 4: Build the search box#
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
export default function AiSearch() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
if (query.trim().length < 3) return;
const controller = new AbortController();
// Wait until the user pauses typing before searching.
const timer = setTimeout(async () => {
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: controller.signal });
setResults((await res.json()).results);
} catch {
// Aborted because the user kept typing.
}
}, 300);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query]);
const visible = query.trim().length < 3 ? [] : results;
return (
<div>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by meaning, e.g. “keep users logged in”"
aria-label="Search posts"
/>
<ul>
{visible.map((r) => (
<li key={r.slug}>
<Link href={`/blog/${r.slug}`}>{r.title}</Link>
</li>
))}
</ul>
</div>
);
}- Debouncing (the 300 ms timer) sends one request when the user pauses, not one per keystroke.
AbortControllercancels the previous request when the user keeps typing, so an old, slow answer can never overwrite a newer one.
Option B: MongoDB Atlas Vector Search#
When you have thousands of documents, content that changes often, or you need filters such as "only Python posts", store vectors in MongoDB and let Atlas do the searching. Create a free cluster at mongodb.com/atlas, then npm install mongodb.
Store documents with their vectors
import { MongoClient } from 'mongodb';
import { readFile } from 'node:fs/promises';
import { embed } from '../lib/embed.js';
const client = new MongoClient(process.env.MONGO_URI);
const posts = client.db('site').collection('posts');
const items = JSON.parse(await readFile('content/posts.json', 'utf8'));
for (const post of items) {
const embedding = await embed(`${post.title}\n\n${post.text}`);
await posts.updateOne(
{ slug: post.slug },
{ $set: { ...post, embedding } },
{ upsert: true }
);
}
await client.close();Create the vector index
Run this once (vector index docs). numDimensions must be 384 to match the model:
await posts.createSearchIndex({
name: 'vector_index',
type: 'vectorSearch',
definition: {
fields: [
{ type: 'vector', path: 'embedding', numDimensions: 384, similarity: 'cosine' },
{ type: 'filter', path: 'category' },
],
},
});The index builds in the background and takes a moment before queries work. The filter field lets you narrow results by category during the search itself.
Query with $vectorSearch
import { MongoClient } from 'mongodb';
import { embed } from './embed.js';
const client = new MongoClient(process.env.MONGO_URI);
const posts = client.db('site').collection('posts');
export async function search(query, { limit = 5, category } = {}) {
const queryVector = await embed(query);
return posts.aggregate([
{
$vectorSearch: {
index: 'vector_index',
path: 'embedding',
queryVector,
numCandidates: limit * 20, // MongoDB suggests at least 20x the limit
limit,
...(category && { filter: { category } }),
},
},
{ $project: { _id: 0, slug: 1, title: 1, score: { $meta: 'vectorSearchScore' } } },
{ $match: { score: { $gte: 0.6 } } },
]).toArray();
}$vectorSearch must be the first stage of the pipeline, and numCandidates controls how many nearby documents Atlas considers before picking the best limit ($vectorSearch docs). Atlas reports cosine scores on a 0 to 1 scale, so its threshold is higher than the raw dot-product threshold in option A; tune it the same way. Swap this function into the API route and nothing else changes.
Getting better results#
- Embed the right text. Titles, summaries and headings usually beat entire articles.
- Chunk long content. For long docs, split them into sections of a few paragraphs, embed each one, and link results to the section heading.
- Combine with keyword search. Exact names, error codes and product IDs are still better matched by keywords. Many sites run both and merge the results ("hybrid search").
- Re-index when content changes, and always use the same model for documents and queries.
- Warm up the model. The first query after a cold start loads the model. On serverless hosts, expect that first request to be slower.
Frequently asked questions#
What is semantic search?
Semantic search finds results by meaning instead of exact words. A search for 'keep users logged in safely' can find an article about JWT refresh tokens even though none of those words match, because both texts are about the same idea.
What are embeddings?
An embedding is a list of numbers that represents the meaning of a piece of text. Texts with similar meaning get similar lists, so you can find related content by comparing the numbers.
Do I need a paid API or vector database for AI search?
No. A small open model such as all-MiniLM-L6-v2 runs locally in Node.js with Transformers.js for free. For a few hundred pages, an in-memory index is enough; for more, MongoDB Atlas Vector Search has a free tier.
How many dimensions does all-MiniLM-L6-v2 produce?
It produces 384-dimensional vectors. Your vector index must be created with numDimensions set to 384 to match.
Why does my search return results that are not relevant?
Vector search always returns the closest matches, even when nothing is truly related. Set a minimum similarity score, and tune it by testing real queries against your content.
Official references#
- Transformers.js documentation, Hugging Face
- all-MiniLM-L6-v2 model card
- Route Handlers and serverExternalPackages, Next.js documentation
- $vectorSearch stage, vector index fields and limitations, MongoDB documentation
Found this useful? You can support my writing on GitHub Sponsors or with a coffee.


