Implement Search Functionality with Claude Code: Postgres, Meilisearch, and Algolia
Build production search with Claude Code: requirements, indexing, sync jobs, filters, debounce UI, tests, and rollout.
Search Is a Product Experience
Search functionality is the experience that takes a user query, finds matching candidates, narrows them with filters, sorts them, and highlights why each result matched. A plain input that runs LIKE '%term%' can be useful for an internal table, but it is not enough for a content site, SaaS dashboard, or monetized resource library. Good search keeps readers moving, exposes older evergreen pages, and turns failed searches into editorial ideas.
Masa’s recurring lesson from site-search work is simple: building the UI first creates rework. The hard decisions are the indexed fields, public/private boundaries, locale separation, ranking, sync timing, and review loop. Claude Code can implement the pieces quickly, but only if you give it a concrete brief.
For Algolia-specific implementation details, see Claude Code Algolia search. For API shape and error handling, pair this with Claude Code API development. For latency and caching work, use Claude Code performance optimization.
Start With Use Cases
Before choosing a search engine, classify the job.
| Use case | Examples | What matters | Best fit |
|---|---|---|---|
| Content and docs search | Blog posts, guides, FAQ | title weight, snippets, tags, locale | Postgres full-text or Meilisearch |
| Catalog search | Products, courses, templates | facets, sorting, synonyms, analytics | Meilisearch or Algolia |
| Admin search | customers, invoices, logs | permissions, exact filters, auditability | Postgres first |
| Multilingual search | localized articles | locale isolation, localized keywords, translated titles | Meilisearch or Algolia |
This framing prevents overengineering. A small Postgres-backed site can start with database full-text search. A content hub that needs typo tolerance and facets often fits Meilisearch. A commercial search experience with analytics, UI widgets, and conversion tuning is usually where Algolia earns its cost.
The Claude Code Requirements Prompt
Use a requirements prompt like this before asking Claude Code to write files:
You are implementing production search in an existing Next.js app.
Goal:
- Search published articles and increase content discovery.
- Support query, locale, category, and tag filters.
- Search title, summary, tags, and body, with title ranked highest.
- Return enough data for highlighting and result cards.
Constraints:
- Never return drafts, private records, emails, internal notes, or restricted content.
- Do not expose admin or write API keys to the browser.
- Use 300 ms debounce and AbortController in the UI.
- Log zero-result queries, slow searches, and clicked results.
Deliverables:
- Decision note comparing Postgres full-text, Meilisearch, and Algolia.
- Index schema.
- Sync job.
- /api/search route.
- React search UI.
- Tests and rollout checklist.
Ask Claude Code to read the existing schema, content frontmatter, auth rules, and URL structure before it edits. Search bugs are often disclosure bugs, not just ranking bugs.
Choose the Search Backend
PostgreSQL’s official Full Text Search documentation covers tsvector, tsquery, ranking, and highlighting. It is a good first choice when the data already lives in Postgres and permissions are complex.
Meilisearch’s first project and filtering, sorting, and faceting docs make it easy to add typo-tolerant content search without building ranking from scratch.
Algolia’s InstantSearch.js and React InstantSearch docs are strongest when the search UI is tied to business outcomes, facets, analytics, and ongoing tuning.
Postgres Index Schema
This is a copy-paste starting point for Postgres-backed article search.
CREATE TABLE IF NOT EXISTS articles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
locale text NOT NULL,
status text NOT NULL CHECK (status IN ('draft', 'published', 'private')),
title text NOT NULL,
summary text NOT NULL,
body text NOT NULL,
category text NOT NULL,
tags text[] NOT NULL DEFAULT '{}',
popularity integer NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now(),
search_vector tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('simple', coalesce(title, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(summary, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(array_to_string(tags, ' '), '')), 'B') ||
setweight(to_tsvector('simple', coalesce(body, '')), 'C')
) STORED
);
CREATE INDEX IF NOT EXISTS articles_search_vector_idx
ON articles USING GIN (search_vector);
CREATE INDEX IF NOT EXISTS articles_locale_status_idx
ON articles (locale, status, updated_at DESC);
The important part is not the table name. It is the ranking model: title first, summary and tags second, body last. That matches how readers judge relevance.
Search API Route
This Next.js route uses parameterized SQL, limits query length, and only returns published records.
// app/api/search/route.ts
import { Pool } from "pg";
import { NextRequest, NextResponse } from "next/server";
export const runtime = "nodejs";
const pool = new Pool({
connectionString: process.env.DATABASE_URL
});
export async function GET(request: NextRequest) {
const q = request.nextUrl.searchParams.get("q")?.trim().slice(0, 80) ?? "";
const locale = request.nextUrl.searchParams.get("locale") ?? "en";
const category = request.nextUrl.searchParams.get("category");
const limit = Math.min(Number(request.nextUrl.searchParams.get("limit") ?? 10), 20);
if (q.length < 2) {
return NextResponse.json({ hits: [], total: 0 });
}
const { rows } = await pool.query(
`
WITH input AS (
SELECT websearch_to_tsquery('simple', $1) AS tsq
)
SELECT id, slug, title, summary, category, updated_at AS "updatedAt",
ts_rank_cd(search_vector, input.tsq) AS rank
FROM articles, input
WHERE status = 'published'
AND locale = $2
AND search_vector @@ input.tsq
AND ($3::text IS NULL OR category = $3)
ORDER BY rank DESC, popularity DESC, updated_at DESC
LIMIT $4;
`,
[q, locale, category, limit]
);
return NextResponse.json({
hits: rows.map((row) => ({
...row,
url: locale === "ja" ? `/blog/${row.slug}` : `/${locale}/blog/${row.slug}`
})),
total: rows.length
});
}
Do not index confidential fields and then “hide” them in the UI. Search APIs and search provider dashboards can still expose indexed content.
Meilisearch Sync Job
When you need typo tolerance, facets, and sorting beyond Postgres ranking, keep your database as the source of truth and sync public records into Meilisearch.
// scripts/sync-meilisearch.ts
import "dotenv/config";
import { MeiliSearch } from "meilisearch";
type ArticleRecord = {
id: string;
title: string;
summary: string;
body: string;
locale: string;
status: "published";
category: string;
tags: string[];
url: string;
popularity: number;
updatedAtTimestamp: number;
};
const client = new MeiliSearch({
host: process.env.MEILISEARCH_HOST ?? "http://127.0.0.1:7700",
apiKey: process.env.MEILISEARCH_ADMIN_KEY
});
const index = client.index<ArticleRecord>("articles");
await index.updateSettings({
searchableAttributes: ["title", "summary", "body", "tags"],
filterableAttributes: ["locale", "status", "category", "tags"],
sortableAttributes: ["updatedAtTimestamp", "popularity"],
displayedAttributes: ["id", "title", "summary", "locale", "category", "tags", "url"]
});
const records: ArticleRecord[] = [
{
id: "en_claude-code-search-functionality",
title: "Implement Search Functionality with Claude Code",
summary: "A practical search guide covering backend choice, indexing, UI, tests, and rollout.",
body: "Public article text extracted from your CMS or MDX source.",
locale: "en",
status: "published",
category: "use-cases",
tags: ["Claude Code", "search", "full-text search"],
url: "/en/blog/claude-code-search-functionality",
popularity: 18,
updatedAtTimestamp: 1780272000
}
];
const task = await index.addDocuments(records, { primaryKey: "id" });
console.log(`Queued Meilisearch task ${task.taskUid}`);
Keep display facets small. For content, start with category, tags, and locale. For permissions, use filters that users do not control directly.
Debounced React UI
The frontend should avoid request races and excessive traffic. Debounce input and cancel stale requests.
// components/ArticleSearchBox.tsx
"use client";
import { useEffect, useMemo, useState } from "react";
type SearchHit = {
id: string;
title: string;
summary: string;
url: string;
category: string;
};
function useDebounce<T>(value: T, delayMs: number) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = window.setTimeout(() => setDebounced(value), delayMs);
return () => window.clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
export function ArticleSearchBox({ locale = "en" }: { locale?: string }) {
const [query, setQuery] = useState("");
const [category, setCategory] = useState("");
const [hits, setHits] = useState<SearchHit[]>([]);
const [loading, setLoading] = useState(false);
const debouncedQuery = useDebounce(query, 300);
const params = useMemo(() => {
const next = new URLSearchParams({ q: debouncedQuery, locale });
if (category) next.set("category", category);
return next;
}, [category, debouncedQuery, locale]);
useEffect(() => {
if (debouncedQuery.trim().length < 2) {
setHits([]);
return;
}
const controller = new AbortController();
setLoading(true);
fetch(`/api/search?${params.toString()}`, { signal: controller.signal })
.then((response) => {
if (!response.ok) throw new Error("Search request failed");
return response.json();
})
.then((data: { hits: SearchHit[] }) => setHits(data.hits))
.catch((error) => {
if (error.name !== "AbortError") console.error(error);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [debouncedQuery, params]);
return (
<section aria-label="Article search">
<input
aria-label="Search keywords"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search Claude Code articles"
/>
<select aria-label="Category" value={category} onChange={(event) => setCategory(event.target.value)}>
<option value="">All</option>
<option value="use-cases">Use cases</option>
<option value="advanced">Advanced</option>
</select>
{loading && <p>Searching...</p>}
<ul>
{hits.map((hit) => (
<li key={hit.id}>
<a href={hit.url}>{hit.title}</a>
<p>{hit.summary}</p>
</li>
))}
</ul>
</section>
);
}
Pitfalls, Tests, and Rollout
Common failures are specific: indexing drafts, exposing admin keys, sending private fields to a search vendor, adding too many synonyms, turning every database column into a facet, and never reviewing zero-result queries. Search quality improves through logs, not guesswork.
Test the helpers and the workflow:
// tests/search-query.test.ts
import { describe, expect, it } from "vitest";
function shouldSearch(query: string) {
return query.trim().length >= 2 && query.length <= 80;
}
describe("search request rules", () => {
it("rejects empty and one-character queries", () => {
expect(shouldSearch("")).toBe(false);
expect(shouldSearch("a")).toBe(false);
expect(shouldSearch("api")).toBe(true);
});
});
Before launch, verify public-only indexing, zero-result UI, p95 latency, query length limits, private-data redaction, mobile layout, and analytics for query, rank, click, and no-result events. After launch, review those logs weekly and feed the findings back to Claude Code as specific ranking, synonym, title, and internal-link tasks.
ClaudeCodeLab handles search design, Claude Code training, and implementation review for content-heavy products. If you want structured help, the training and consultation page is the soft next step.
Summary
Build search in this order: requirements, backend choice, index schema, sync job, filters and facets, debounced UI, tests, then rollout. In practice, narrowing indexed fields early produced the fewest later fixes, and reviewing zero-result searches gave the most useful content ideas for PV growth.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.