Back to blog
October 4, 2026•52 views

How to Implement AI-Powered Search in Next.js with OpenAI and Typesense

Next.jsOpenAITypesenseAI searchsemantic searchvector searchMCP
How to Implement AI-Powered Search in Next.js with OpenAI and Typesense

Search is the feature everyone underestimates until users start complaining. I have shipped search on everything from a 40-page WordPress brochure site to a multi-tenant SaaS dashboard, and the pattern is always the same: keyword search feels fine in the demo and falls apart in production. Someone types "how do I cancel my plan and get a refund" and your LIKE '%cancel%' query returns a pricing page from 2021.

That is the gap AI search fills. In this tutorial I will show you how I build AI search in Next.js using Typesense as the retrieval layer and OpenAI for embeddings plus answer generation. This is the same architecture I use for internal knowledge tools and product search, and it runs comfortably on a single VPS if you keep an eye on cost.

Why not just pipe everything into an LLM?

Because it is slow and expensive, and it hallucinates. If you feed an entire documentation set into a chat completion on every keystroke, you are paying for tokens you did not need and waiting seconds for a response. Retrieval-augmented generation solves this: find the 5 to 10 most relevant chunks first, then let the model write an answer grounded in those chunks.

Typesense does the "find" part. It gives you typo tolerance, faceting, filters, and vector search in one engine, with a single binary you can run in Docker. That combination matters more than people admit. Pure vector search is bad at exact matches (SKUs, error codes, names). Pure keyword search is bad at intent. You want both, and Typesense supports hybrid queries out of the box.

The architecture

Here is the flow I keep coming back to:

  1. Content gets chunked and embedded offline (or on publish) via a script.
  2. Chunks go into a Typesense collection with both a text field and a vector field.
  3. The Next.js app sends the user query to a Route Handler or Server Action.
  4. That handler embeds the query with OpenAI and runs a hybrid search in Typesense.
  5. Top results are passed to a chat completion that streams an answer back to the client.
  6. The client renders the streaming answer plus the source cards.

Two API calls per query, one database call. That is the whole thing.

If you are still deciding between a Route Handler and a Server Action for the write path, my Next.js 15 Server Actions tutorial walks through form handling and data mutations in detail. And if you are new to the App Router's rendering model, React Server Components vs Client Components in Next.js 15 is the primer I hand to every new hire.

Step 1: Run Typesense locally

Do not start with a managed cluster. Get it running locally first.

# docker-compose.yml
services:
  typesense:
    image: typesense/typesense:27.1
    ports:
      - "8108:8108"
    volumes:
      - ./typesense-data:/data
    command: >
      --data-dir /data
      --api-key=dev-only-key
      --enable-cors

Then docker compose up -d. Typesense is written in C++ and starts in under a second. If you have only ever run Elasticsearch, the resource difference will surprise you. (Check the Typesense Docker image tags for the current release before you pin a version.)

Create the collection

I keep two fields that matter: embedding for the vector and content for the text. The rest is metadata I want to filter or display.

// scripts/create-collection.ts
import Typesense from "typesense";

const client = new Typesense.Client({
  nodes: [{ host: "localhost", port: 8108, protocol: "http" }],
  apiKey: "dev-only-key",
  connectionTimeoutSeconds: 5,
});

await client.collections().create({
  name: "docs",
  fields: [
    { name: "title", type: "string" },
    { name: "content", type: "string" },
    { name: "url", type: "string", index: false },
    { name: "category", type: "string", facet: true },
    { name: "embedding", type: "float[]", num_dim: 1536 },
    { name: "updated_at", type: "int64" },
  ],
  default_sorting_field: "updated_at",
});

Note num_dim: 1536. That is text-embedding-3-small. If you switch models, you rebuild the collection. There is no migration path for dimension changes, so pick early.

Step 2: Embed your content

I keep embedding in a script that runs on publish or on a schedule, never in the request path. Embedding 5,000 chunks in a web request is a good way to hit a timeout and a rate limit at the same time.

// scripts/index-docs.ts
import OpenAI from "openai";
import Typesense from "typesense";
import { chunk } from "./chunk";

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const client = new Typesense.Client({
  nodes: [{ host: "localhost", port: 8108, protocol: "http" }],
  apiKey: "dev-only-key",
});

const docs = await loadDocs(); // your CMS, markdown files, database rows
const chunks = docs.flatMap(chunk); // ~400 tokens with 50 token overlap

// Batch embeddings: 100 inputs per request is a sane default
for (let i = 0; i < chunks.length; i += 100) {
  const batch = chunks.slice(i, i + 100);
  const res = await openai.embeddings.create({
    model: "text-embedding-3-small",
    input: batch.map((c) => c.content),
  });

  await client.collections("docs").documents().import(
    batch.map((c, idx) => ({
      id: c.id,
      title: c.title,
      content: c.content,
      url: c.url,
      category: c.category,
      updated_at: c.updatedAt,
      embedding: res.data[idx].embedding,
    })),
    { action: "upsert" }
  );
}

Two things I learned the hard way. First, always use action: "upsert" so re-running the script is safe. Second, normalize your chunk IDs deterministically (a hash of the source URL plus the chunk index works) so you never end up with duplicates after a partial run.

Chunking is where quality is won or lost

I have seen more search quality come from better chunking than from a better model. My defaults:

  • 300 to 500 tokens per chunk.
  • 10 to 15 percent overlap so you do not cut a sentence in half at the boundary.
  • Split on headings first, then paragraphs, then sentences.
  • Keep the heading path in the chunk text (for example, "Pricing > Refunds > Eligibility"). It gives the embedding context it otherwise loses.

If your content has tables, convert them to a readable sentence form before embedding. Raw pipe characters embed poorly.

Step 3: The Next.js search route

I use a Route Handler for this because I want the response streamed and I want to call it from both server and client components.

// app/api/search/route.ts
import OpenAI from "openai";
import Typesense from "typesense";

export const runtime = "nodejs";

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const client = new Typesense.Client({
  nodes: [{ host: process.env.TYPESENSE_HOST! port: 443, protocol: "https" }],
  apiKey: process.env.TYPESENSE_API_KEY! connectionTimeoutSeconds: 5,
});

export async function POST(req: Request) {
  const { query } = await req.json();

  if (!query || query.length > 500) {
    return Response.json({ error: "Invalid query" }, { status: 400 });
  }

  const embedding = await openai.embeddings.create({
    model: "text-embedding-3-small",
    input: query,
  });

  const results = await client.collections("docs").documents().search({
    q: query,
    query_by: "title,content",
    vector_query: `embedding:([${embedding.data[0].embedding.join(",")}], k:8)`,
    exclude_fields: "embedding",
    per_page: 8,
  });

  const hits = results.hits ?? [];
  const context = hits
    .map((h, i) => `[${i + 1}] ${h.document.title}\n${h.document.content}`)
    .join("\n\n");

  const stream = await openai.chat.completions.create({
    model: "gpt-4o-mini",
    stream: true,
    temperature: 0.2,
    messages: [
      {
        role: "system",
        content:
          "Answer using only the provided context. Cite sources as [1], [2]. If the context does not contain the answer, say so plainly.",
      },
      { role: "user", content: `Context:\n${context}\n\nQuestion: ${query}` },
    ],
  });

  return new Response(stream.toReadableStream(), {
    headers: { "Content-Type": "text/plain; charset=utf-8" },
  });
}

The vector_query syntax is the part people get wrong. k:8 is how many nearest neighbours you pull. I usually start at 8 and tune from there. If you set k too high you flood the prompt with irrelevant chunks and the model starts inventing connections between them. The full syntax is documented in the Typesense vector search guide.

Hybrid beats pure vector every time

Add alpha to blend keyword and vector scoring:

vector_query: `embedding:([${vector}], k:8, alpha:0.3)`

alpha: 0 is pure keyword, alpha: 1 is pure vector. I land between 0.2 and 0.4 for documentation and closer to 0.6 for conversational support content. Test with real queries from your logs, not queries you invent.

Step 4: Client UI with streaming

If you are on the App Router, a small client component handles this fine. useChat from the Vercel AI SDK is a reasonable shortcut, but the raw implementation below has fewer moving parts if you want to understand the flow.

"use client";
import { useState } from "react";

export function AISearch() {
  const [query, setQuery] = useState("");
  const [answer, setAnswer] = useState("");
  const [loading, setLoading] = useState(false);

  async function run() {
    setLoading(true);
    setAnswer("");
    const res = await fetch("/api/search", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ query }),
    });

    const reader = res.body!.getReader();
    const decoder = new TextDecoder();

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      setAnswer((prev) => prev + decoder.decode(value, { stream: true }));
    }
    setLoading(false);
  }

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Ask anything"
      />
      <button onClick={run} disabled={loading}>Search</button>
      <article>{answer}</article>
    </div>
  );
}

There is a subtlety here that bites people: if you are parsing the raw OpenAI stream, you get SSE-formatted chunks with data: prefixes, not plain text. Either pipe through a helper that strips the prefix, or use stream.toReadableStream() as above and send plain text. Plain text is simpler and works fine for a single answer stream.

Production concerns nobody writes about

This is the section I wish existed when I built my first version.

Concern What I do Why
Cost per query text-embedding-3-small for both indexing and query, gpt-4o-mini for answers Embedding cost is negligible, generation dominates
Latency Parallelise embedding and Typesense warm-up, stream tokens Perceived speed matters more than total time
Caching Redis with a 24 hour TTL keyed on a normalized query hash Repeated questions are the norm, not the exception
Rate limiting Upstash Ratelimit or a simple token bucket in Redis, 10 req/min per IP One scraper can burn your OpenAI budget overnight
Abuse Cap query length, strip control characters, log queries Prompt injection through search input is real
Fallback If OpenAI is down, return Typesense results with no answer Degrade, do not fail
Observability Log query, hit count, latency, token usage You cannot tune what you do not measure

The caching point deserves emphasis. On a support site I worked on, roughly a third of queries were near-duplicates of each other. Normalize by lowercasing, trimming, and collapsing whitespace before hashing, and you get a meaningful hit rate for almost no effort.

If you are exposing this endpoint to anything beyond a browser, read Securing AI Agents in Production before you ship. An unauthenticated search endpoint that spends money on every call is a target.

Bangla and low-bandwidth considerations

A lot of my clients are in Bangladesh, and this changes the design in two ways.

First, text-embedding-3-small handles Bangla reasonably but not brilliantly. For Bangla-heavy content I index two versions of each chunk: the original and a translated English version, then search both and merge. It sounds wasteful, but the retrieval quality jump is worth the storage.

Second, assume the connection is slow. That means:

  • Do not ship a 400KB search UI. Keep the client component lean.
  • Stream the answer so the user sees progress within a second.
  • Skip the fancy skeleton animations and render text as it arrives.
  • Consider pre-computing embeddings for your top 200 queries and caching the full answer.

If you are serving users in Dhaka on a 3G connection, a 200ms server response still feels like two seconds. Streaming fixes the perception problem.

Where MCP fits

I build MCP servers professionally, and search is one of the most natural tools to expose. Once your Typesense index is populated, wrapping the search as an MCP tool means Claude Desktop, Cursor, or any MCP-compatible client can query your documentation directly. You write the retrieval logic once and reuse it across a web UI and an AI assistant.

The tool definition is small:

{
  name: "search_docs",
  description: "Search product documentation and return relevant passages",
  inputSchema: {
    type: "object",
    properties: { query: { type: "string" } },
    required: ["query"],
  },
}

The handler is basically the first half of the Route Handler above. If you already built the API route, you are 80 percent done. For a full walkthrough of the server side, see How to Build an MCP Server for Business Automation, and for the wider picture of where AI tools fit into an existing stack, MCP Servers for Business is the starting point.

Common mistakes I see in code reviews

  • Embedding the query with a different model than the one used for indexing. The vectors are not comparable. This is the number one bug.
  • Forgetting to exclude the embedding field from search results. You ship 1,536 floats to the browser and wonder why the payload is 80KB.
  • Running embedding inside a Server Component render. It re-runs on every request and you cannot cache it properly.
  • No k tuning. Defaults are fine to start, but the right value depends on your chunk size.
  • Trusting the model to say "I do not know". It will not unless you tell it to, explicitly, in the system prompt.

When should you not build this?

If you have fewer than 200 pages of content, a well-tuned Typesense keyword search with synonyms will outperform an AI answer layer and cost nothing. If your users are searching for exact identifiers, skip the LLM entirely. The AI layer earns its keep when content is large, language is conversational, and users ask questions rather than type keywords.

I have talked more than one founder out of building this. It is a feature, not a strategy, and it needs a content pipeline behind it to stay useful.

Wrapping up

AI search in Next.js is not complicated once you separate retrieval from generation. Typesense handles finding, OpenAI handles writing, and your job is to make the seam between them clean. Get the embeddings consistent, tune k and alpha against real queries, cache aggressively, and stream the response.

If you are building this and want a second pair of eyes on the architecture, or you need someone to take it from prototype to production, I do this for clients across Bangladesh, Australia, the USA, Canada and New Zealand. Reach out through the site and tell me what you are working on.

Frequently asked questions

Do I need a separate vector database if I already use Typesense?

No. Typesense supports vector fields natively, so you can run hybrid keyword and vector search in one query. Adding Pinecone or Weaviate on top usually means two round trips and two failure points for no real gain at typical documentation scale.

Which OpenAI embedding model should I use for Next.js search?

text-embedding-3-small is the right default. It is cheap, fast, and 1536 dimensions is plenty for most content. Use text-embedding-3-large only if you have measured a retrieval quality problem that smaller dimensions cannot solve, and remember you must reindex everything when you switch.

How do I stop the AI search from hallucinating answers?

Restrict the prompt to the retrieved context, tell the model explicitly to say when the context does not contain the answer, keep temperature low (0.1 to 0.3), and always show the source chunks alongside the answer so users can verify. No prompt eliminates hallucination, but grounding plus visible sources gets it low enough to trust.

Can I run this without a paid OpenAI account?

You can swap in a local model. Ollama with nomic-embed-text for embeddings and a small Llama or Qwen model for generation works, though answer quality drops and you need a machine with enough RAM. Typesense stays the same either way, which is the point of keeping retrieval and generation separate.

Share this post

Native Web Share is available on mobile — that share sheet includes Instagram and any other app you have installed.

Let's Connect

Get In Touch

Let's build something premium together.

Contact info

Follow me
Chat on WhatsApp