Building a conversational search interface with Next.js and Claude

By Reza Mohammadi · 16 July 20260 views

What Conversational Search Actually Solves

Keyword search forces users to translate their intent into a query language the system understands. Users search for "shoes that don't hurt after long days standing" because they know the system can't understand "comfortable work shoes for nurses." They add and remove terms, scan result pages, refine, and repeat. The cognitive load belongs to the user.

Conversational search inverts this: the user states their intent naturally and the system takes on the translation work. It can ask clarifying questions, maintain context across turns ("show me cheaper ones"), and explain why results are relevant. The difference in experience is substantial, and the implementation — now that streaming LLM APIs are mature — is achievable in a small codebase.

The architecture in this guide has four components:

  1. A Next.js Route Handler that streams Claude responses
  2. A search execution layer that translates the LLM's tool calls into real queries
  3. A streaming client component that renders the response incrementally
  4. Conversation history stored in the session to support multi-turn dialogue

Project Setup and Dependencies

npx create-next-app@latest conversational-search --typescript --app --tailwind
cd conversational-search
npm install @anthropic-ai/sdk

Set your API key in .env.local:

ANTHROPIC_API_KEY=your_key_here

The file structure we're building toward:

app/
  api/
    search/
      route.ts          # Streaming Route Handler
  search/
    page.tsx            # Search page (Server Component shell)
    SearchInterface.tsx # Client Component with streaming UI
lib/
  search.ts             # Search execution (vector DB, Elasticsearch, etc.)
  types.ts              # Shared types

The Route Handler: Streaming Claude with Tool Use

The Route Handler is where Claude processes the user's message, decides whether to execute a search, and streams its response back. Claude uses tool use to signal when it wants to run a search, and the handler executes it and feeds results back:

// app/api/search/route.ts
import Anthropic from "@anthropic-ai/sdk";
import { NextRequest } from "next/server";
import { executeSearch } from "@/lib/search";

const client = new Anthropic();

const SEARCH_TOOL: Anthropic.Tool = {
  name: "search_catalogue",
  description:
    "Search the product catalogue with natural language. Returns structured results including name, description, price, and relevance score.",
  input_schema: {
    type: "object" as const,
    properties: {
      query: {
        type: "string",
        description: "The search query derived from the user's intent",
      },
      filters: {
        type: "object",
        properties: {
          max_price: { type: "number" },
          category: { type: "string" },
          in_stock_only: { type: "boolean" },
        },
      },
      limit: {
        type: "number",
        description: "Number of results to return (default 5, max 20)",
        default: 5,
      },
    },
    required: ["query"],
  },
};

const SYSTEM_PROMPT = `You are a helpful product search assistant. 

When a user asks about products:
1. Use the search_catalogue tool to find relevant results
2. Present results clearly, highlighting why each is relevant to their query
3. Ask clarifying questions if the intent is ambiguous (e.g., "Did you mean X or Y?")
4. Remember context from earlier in the conversation — if they say "cheaper ones", search with lower price filters
5. If no results match, explain what you searched for and suggest alternative queries

Never make up product information. Only describe products that appear in search results.`;

export async function POST(request: NextRequest) {
  const { messages } = await request.json() as {
    messages: Anthropic.MessageParam[];
  };

  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      try {
        let currentMessages = [...messages];

        // Agentic loop: keep going until Claude stops using tools
        while (true) {
          const response = await client.messages.create({
            model: "claude-opus-4-5",
            max_tokens: 2048,
            system: SYSTEM_PROMPT,
            tools: [SEARCH_TOOL],
            messages: currentMessages,
            stream: true,
          });

          let fullText = "";
          let toolUseBlocks: Anthropic.ToolUseBlock[] = [];
          let stopReason: string | null = null;

          for await (const event of response) {
            if (event.type === "content_block_delta") {
              if (event.delta.type === "text_delta") {
                const chunk = event.delta.text;
                fullText += chunk;
                // Stream text chunks to the client
                controller.enqueue(
                  encoder.encode(`data: ${JSON.stringify({ type: "text", content: chunk })}\n\n`)
                );
              } else if (event.delta.type === "input_json_delta") {
                // Tool input is accumulated but not streamed (partial JSON is not useful)
              }
            } else if (event.type === "content_block_start") {
              if (event.content_block.type === "tool_use") {
                toolUseBlocks.push(event.content_block);
              }
            } else if (event.type === "message_delta") {
              stopReason = event.delta.stop_reason ?? null;
            }
          }

          if (stopReason !== "tool_use" || toolUseBlocks.length === 0) {
            // No more tool calls — we're done
            break;
          }

          // Execute tool calls and continue the loop
          const toolResults: Anthropic.ToolResultBlockParam[] = [];

          for (const toolUse of toolUseBlocks) {
            if (toolUse.name === "search_catalogue") {
              const input = toolUse.input as {
                query: string;
                filters?: Record<string, unknown>;
                limit?: number;
              };

              // Signal to the client that a search is running
              controller.enqueue(
                encoder.encode(
                  `data: ${JSON.stringify({ type: "search_start", query: input.query })}\n\n`
                )
              );

              const results = await executeSearch(input.query, input.filters, input.limit ?? 5);

              controller.enqueue(
                encoder.encode(
                  `data: ${JSON.stringify({ type: "search_complete", results })}\n\n`
                )
              );

              toolResults.push({
                type: "tool_result",
                tool_use_id: toolUse.id,
                content: JSON.stringify(results),
              });
            }
          }

          // Update message history for the next iteration
          const assistantMessage: Anthropic.MessageParam = {
            role: "assistant",
            content: [
              ...(fullText ? [{ type: "text" as const, text: fullText }] : []),
              ...toolUseBlocks,
            ],
          };

          currentMessages = [
            ...currentMessages,
            assistantMessage,
            { role: "user", content: toolResults },
          ];
        }

        controller.enqueue(encoder.encode("data: [DONE]\n\n"));
      } catch (error) {
        controller.enqueue(
          encoder.encode(
            `data: ${JSON.stringify({ type: "error", message: String(error) })}\n\n`
          )
        );
      } finally {
        controller.close();
      }
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
    },
  });
}

The Search Execution Layer

Decouple search execution from the LLM layer so you can swap backends without touching the Route Handler:

// lib/search.ts
export interface SearchResult {
  id: string;
  name: string;
  description: string;
  price: number;
  category: string;
  inStock: boolean;
  relevanceScore: number;
  imageUrl?: string;
}

export async function executeSearch(
  query: string,
  filters?: Record<string, unknown>,
  limit = 5
): Promise<SearchResult[]> {
  // Replace this with your actual search backend:
  // - Elasticsearch with semantic re-ranking
  // - pgvector cosine similarity search
  // - Algolia with neural search
  // - Typesense with vector search

  const params = new URLSearchParams({ q: query, limit: String(limit) });

  if (filters?.max_price) params.set("max_price", String(filters.max_price));
  if (filters?.category) params.set("category", String(filters.category));
  if (filters?.in_stock_only) params.set("in_stock_only", "true");

  const response = await fetch(`${process.env.SEARCH_API_URL}/search?${params}`, {
    headers: { Authorization: `Bearer ${process.env.SEARCH_API_KEY}` },
  });

  if (!response.ok) {
    throw new Error(`Search API error: ${response.status}`);
  }

  return response.json();
}

The Streaming Client Component

The client component handles SSE parsing, renders the streaming response, and manages conversation history:

// app/search/SearchInterface.tsx
"use client";

import { useState, useRef, useEffect } from "react";
import type { SearchResult } from "@/lib/search";

interface Message {
  role: "user" | "assistant";
  content: string;
  searchResults?: SearchResult[];
  isStreaming?: boolean;
}

export default function SearchInterface() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState("");
  const [isLoading, setIsLoading] = useState(false);
  const [searchingFor, setSearchingFor] = useState<string | null>(null);
  const messagesEndRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);

  // Build the message history in Anthropic format for the API
  const buildApiMessages = () =>
    messages
      .filter((m) => !m.isStreaming)
      .map((m) => ({ role: m.role, content: m.content }));

  const sendMessage = async () => {
    const userInput = input.trim();
    if (!userInput || isLoading) return;

    const userMessage: Message = { role: "user", content: userInput };
    const assistantMessage: Message = {
      role: "assistant",
      content: "",
      isStreaming: true,
    };

    setMessages((prev) => [...prev, userMessage, assistantMessage]);
    setInput("");
    setIsLoading(true);

    try {
      const response = await fetch("/api/search", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          messages: [...buildApiMessages(), { role: "user", content: userInput }],
        }),
      });

      const reader = response.body!.getReader();
      const decoder = new TextDecoder();
      let accumulatedText = "";
      let searchResults: SearchResult[] | undefined;

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        const chunk = decoder.decode(value, { stream: true });
        const lines = chunk.split("\n");

        for (const line of lines) {
          if (!line.startsWith("data: ") || line === "data: [DONE]") continue;

          const data = JSON.parse(line.slice(6));

          if (data.type === "text") {
            accumulatedText += data.content;
            setMessages((prev) => {
              const updated = [...prev];
              updated[updated.length - 1] = {
                ...updated[updated.length - 1],
                content: accumulatedText,
              };
              return updated;
            });
          } else if (data.type === "search_start") {
            setSearchingFor(data.query);
          } else if (data.type === "search_complete") {
            searchResults = data.results;
            setSearchingFor(null);
          }
        }
      }

      // Finalise the assistant message
      setMessages((prev) => {
        const updated = [...prev];
        updated[updated.length - 1] = {
          role: "assistant",
          content: accumulatedText,
          searchResults,
          isStreaming: false,
        };
        return updated;
      });
    } catch (error) {
      console.error("Search error:", error);
    } finally {
      setIsLoading(false);
      setSearchingFor(null);
    }
  };

  return (
    <div className="flex flex-col h-screen max-w-3xl mx-auto p-4">
      <div className="flex-1 overflow-y-auto space-y-4 pb-4">
        {messages.length === 0 && (
          <div className="text-center text-gray-500 mt-20">
            <p className="text-xl font-medium">What are you looking for?</p>
            <p className="text-sm mt-2">Try "wireless headphones under $100" or "gifts for a 5-year-old"</p>
          </div>
        )}

        {messages.map((message, index) => (
          <div key={index} className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}>
            <div
              className={`max-w-2xl rounded-2xl px-4 py-3 ${
                message.role === "user"
                  ? "bg-blue-600 text-white"
                  : "bg-gray-100 text-gray-900"
              }`}
            >
              <p className="whitespace-pre-wrap">{message.content}</p>
              {message.isStreaming && (
                <span className="inline-block w-1 h-4 bg-current animate-pulse ml-1" />
              )}
              {message.searchResults && message.searchResults.length > 0 && (
                <SearchResultsGrid results={message.searchResults} />
              )}
            </div>
          </div>
        ))}

        {searchingFor && (
          <div className="flex justify-start">
            <div className="bg-gray-100 rounded-2xl px-4 py-3 text-gray-500 text-sm">
              Searching for "{searchingFor}"...
            </div>
          </div>
        )}

        <div ref={messagesEndRef} />
      </div>

      <div className="flex gap-2 pt-4 border-t">
        <input
          type="text"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && sendMessage()}
          placeholder="Describe what you're looking for..."
          className="flex-1 border rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500"
          disabled={isLoading}
        />
        <button
          onClick={sendMessage}
          disabled={isLoading || !input.trim()}
          className="bg-blue-600 text-white rounded-xl px-6 py-3 disabled:opacity-50 hover:bg-blue-700 transition-colors"
        >
          Send
        </button>
      </div>
    </div>
  );
}

function SearchResultsGrid({ results }: { results: SearchResult[] }) {
  return (
    <div className="mt-3 grid grid-cols-1 gap-2">
      {results.map((result) => (
        <div key={result.id} className="bg-white rounded-xl p-3 border border-gray-200">
          <div className="flex justify-between items-start">
            <div>
              <p className="font-medium text-gray-900">{result.name}</p>
              <p className="text-sm text-gray-500 mt-1 line-clamp-2">{result.description}</p>
            </div>
            <p className="text-lg font-semibold text-green-600 ml-4 shrink-0">
              ${result.price.toFixed(2)}
            </p>
          </div>
          <div className="flex items-center gap-2 mt-2">
            <span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
              {result.category}
            </span>
            {!result.inStock && (
              <span className="text-xs text-red-500">Out of stock</span>
            )}
          </div>
        </div>
      ))}
    </div>
  );
}

Multi-Turn Context and Session Persistence

The current implementation holds conversation history in React state, which is lost on page refresh. For a production system, persist conversations server-side:

// lib/conversation-store.ts
import { cookies } from "next/headers";

export async function getConversationHistory(conversationId: string) {
  // Store in your database of choice — Firestore, Redis, PostgreSQL
  const response = await fetch(
    `${process.env.API_URL}/conversations/${conversationId}`,
    { next: { revalidate: 0 } }
  );
  if (!response.ok) return [];
  return response.json();
}

export async function appendToConversation(
  conversationId: string,
  messages: Array<{ role: string; content: string }>
) {
  await fetch(`${process.env.API_URL}/conversations/${conversationId}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ messages }),
  });
}

Pass the conversation ID from a cookie so returning users continue their session:

// app/api/search/route.ts (updated)
export async function POST(request: NextRequest) {
  const cookieStore = await cookies();
  const conversationId = cookieStore.get("conversation_id")?.value ?? crypto.randomUUID();

  const history = await getConversationHistory(conversationId);
  const { newMessage } = await request.json();

  const allMessages = [...history, { role: "user", content: newMessage }];
  // ... rest of the streaming logic
}

Latency Optimisation

Conversational search has an inherently higher latency floor than keyword search because it requires at least one LLM round-trip. Minimise this with:

Parallel tool execution: if Claude calls multiple tools in one response, execute them concurrently:

const toolResults = await Promise.all(
  toolUseBlocks.map(async (toolUse) => {
    const results = await executeSearch(/* ... */);
    return { type: "tool_result" as const, tool_use_id: toolUse.id, content: JSON.stringify(results) };
  })
);

Stream immediately: the SSE architecture in this guide starts streaming Claude's text the moment tokens arrive, so users see the response building in real time even before the final answer is complete.

Cache common queries: frequent natural language queries often map to the same underlying search. Cache the search results layer (not the LLM responses, which should always be generated fresh) with a short TTL:

const cacheKey = `search:${query}:${JSON.stringify(filters)}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const results = await runActualSearch(query, filters, limit);
await redis.setex(cacheKey, 300, JSON.stringify(results)); // 5-minute TTL
return results;

The pattern scales to any search domain — e-commerce, internal knowledge bases, documentation, support articles — because the search execution layer is fully swappable. The LLM layer stays identical regardless of what sits behind the search interface.

Comments

No comments yet. Be the first!

Sign in to leave a comment.

Building a conversational search interface with Next.js and Claude — ANN Tech