kfich.dev
Back to blog

April 10, 2026 · 6 min read

Building Smart Search with the Anthropic API

AnthropicAINext.jsTypeScript

The smart search on my projects page is powered by a single API call to claude-haiku-4-5-20251001. Here's how I built it, what I learned, and the tradeoffs I made.

The problem with keyword search

When I first built this portfolio, the projects page had a simple filter: click a tag, get matching projects. It worked, but it was rigid. Searching for "backend work" returned nothing even though several API and Node.js projects were clearly backend work.

What I wanted was a search that understood intent, not just keywords.

The architecture

The setup is minimal:

  1. A Next.js route handler at /api/projects/search receives the query
  2. It serializes the project catalog as JSON and sends it to Claude along with the user's query
  3. Claude returns a ranked list of matching project IDs with a short reason for each match
  4. The UI renders the matched projects with the reason shown as a callout on each card

No embeddings, no vector database, no RAG pipeline. Just a prompt.

The prompt

You are a project search assistant. Given a user query and a list of projects,
return the matching project IDs ranked by relevance. Only include projects that
genuinely match the query intent.

Return a JSON object with this exact shape:
{
  "results": [
    { "projectId": "<id>", "reason": "<one short sentence explaining why this matches>" },
    ...
  ]
}

Return an empty results array if nothing matches.

The key constraint is Only include projects that genuinely match. Without it, Claude will often return everything with increasingly thin justifications.

Why Haiku?

Haiku is fast and cheap — typically under 500ms for this use case. The task is straightforward enough that a smaller model handles it well. I'm sending 6 project summaries and asking for a structured output; this doesn't require Opus or Sonnet.

I do strip the longDescription field before sending to keep token counts low. The description, tags, category, status, and year fields are enough for Claude to make good matches.

Parsing the response

Claude reliably returns valid JSON, but it sometimes wraps it in a markdown code block. A simple regex handles this:

const jsonMatch = content.text.match(/\{[\s\S]*\}/)

I also validate that returned IDs actually exist in the catalog before rendering:

const validIds = new Set(projects.map((p) => p.id))
const safeResults = parsed.results.filter(
  (r) => validIds.has(r.projectId) && typeof r.reason === "string"
)

Rate limiting

The API route uses an in-memory map keyed by IP to limit requests to 20 per minute. It's not production-grade — a deployed app should use Redis or a KV store — but it's enough for a portfolio site that doesn't see sustained traffic.

What surprised me

The quality of the match reasons. I expected terse one-liners but Claude often writes surprisingly useful explanations, like "This project uses Fastify and Redis, both of which are backend infrastructure technologies." That ended up being the most useful part of the feature from a UX perspective.

What I'd do differently

If the catalog grew to dozens of projects, I'd pre-embed descriptions and do a vector similarity search first, then pass only the top candidates to Claude for re-ranking and reasoning. The current approach sends the full catalog every time, which scales linearly with project count.

The code is in this repo if you want to take a look.