Membangun Search Functionality dengan Claude Code: Postgres, Meilisearch, Algolia
Panduan praktis search dengan Claude Code: requirements, index, sync job, filter, debounce UI, testing, dan rollout.
Search bukan sekadar kotak input
Search functionality adalah pengalaman yang menerima input pengguna, menemukan kandidat yang cocok, lalu mengembalikan hasil dengan filter, sorting, dan highlight. Query sederhana sepertiLIKE '%term%' bisa cukup untuk tabel internal, tetapi tidak cukup untuk blog, knowledge base, katalog kursus, atau produk SaaS yang mengejar PV dan monetisasi.
Pengalaman praktis Masa: jika UI dibuat lebih dulu, pekerjaan ulang hampir pasti muncul. Field yang diindeks, batas data publik dan privat, locale, ranking, sync job, serta review log harus ditentukan sejak awal. Claude Code bisa menulis cepat, tetapi kualitas hasil tergantung pada brief yang jelas.
Baca juga Claude Code Algolia search, Claude Code API development, dan Claude Code performance optimization.
Mulai dari use case
| Use case | Contoh | Yang penting | Pilihan cocok |
|---|---|---|---|
| Content search | blog, FAQ, docs | bobot judul, ringkasan, tag, locale | Postgres full-text atau Meilisearch |
| Catalog search | produk, kursus, template | facet, sorting, sinonim, analytics | Meilisearch atau Algolia |
| Admin search | customer, invoice, log | permission, filter presisi, audit | Postgres dulu |
| Multilingual search | artikel id/en/ja | pemisahan locale, keyword lokal | Meilisearch atau Algolia |
Untuk situs kecil yang datanya sudah ada di Postgres, full-text search adalah awal yang masuk akal. Jika butuh typo tolerance, facet, dan relevansi yang lebih siap, Meilisearch mudah dioperasikan. Jika search langsung memengaruhi revenue atau conversion, Algolia lebih kuat.
Requirements prompt untuk Claude Code
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 fields needed for result cards and highlighting.
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.
Minta Claude Code membaca schema, MDX frontmatter, aturan auth, dan struktur URL sebelum mengedit. Dalam search, risiko terbesar sering kali bukan ranking buruk, melainkan data privat ikut masuk index.
Memilih backend search
Dokumentasi resmi PostgreSQL untuk Full Text Search menjelaskantsvector, tsquery, dan ranking. Meilisearch menyediakan quick start serta panduan filtering, sorting, and faceting. Algolia unggul untuk UI dan analytics melalui InstantSearch.js dan React InstantSearch.
Schema Postgres
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);
Kuncinya adalah bobot: judul lebih kuat daripada body, summary dan tags berada di tengah. Ini membuat ranking lebih dekat dengan harapan pembaca.
Sync job Meilisearch
Database atau CMS tetap menjadi source of truth. Index search hanya menerima field publik.
// 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 task = await index.addDocuments(
[
{
id: "id_claude-code-search-functionality",
title: "Membangun Search Functionality dengan Claude Code",
summary: "Panduan backend, index, UI, testing, dan rollout untuk search.",
body: "Teks artikel publik yang diekstrak dari MDX atau CMS.",
locale: "id",
status: "published",
category: "use-cases",
tags: ["Claude Code", "search functionality", "pencarian"],
url: "/id/blog/claude-code-search-functionality",
popularity: 18,
updatedAtTimestamp: 1780272000
}
],
{ primaryKey: "id" }
);
console.log(`Queued Meilisearch task ${task.taskUid}`);
Facet jangan terlalu banyak. Untuk content search,category, tags, danlocale biasanya cukup. Filter permission harus dikunci di server atau search key terbatas.
React UI dengan debounce
// 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 = "id" }: { 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>
);
}
Testing, rollout, dan jebakan umum
Jebakan umum: draft ikut terindeks, admin key masuk browser, field privat dikirim ke provider search, sinonim terlalu luas, semua kolom dijadikan facet, dan zero-result query tidak pernah direview.
// 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);
});
});
Sebelum rollout, cek index hanya berisi data publik, UI untuk zero result ada, p95 latency masuk target, query panjang dipotong, log tidak menyimpan data personal, dan layout mobile rapi. Setelah launch, review zero-result dan low-click queries setiap minggu untuk memperbaiki judul, sinonim, internal link, dan ide artikel baru.
ClaudeCodeLab membantu desain search, training Claude Code, dan review implementasi. Untuk dukungan terstruktur, buka training and consultation page.
Ringkasan
Urutan yang aman adalah requirements, pilihan backend, index schema, sync job, filters/facets, debounce UI, tests, lalu rollout. Dari praktik, membatasi field yang diindeks dan dikembalikan sejak awal mengurangi revisi, sementara zero-result logs memberi ide paling konkret untuk pertumbuhan PV.
PDF gratis: cheatsheet Claude Code
Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.
Kami menjaga datamu dan tidak mengirim spam.
Tentang penulis
Masa
Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.
Artikel terkait
Workflow Obsidian ke CLAUDE.md untuk Claude Code
Ubah catatan kerja Obsidian menjadi operating note CLAUDE.md agar konteks tidak dijelaskan ulang.
Claude Code Revenue CTA Routing: dari artikel ke PDF, Gumroad, dan konsultasi
Workflow Claude Code untuk mengarahkan pembaca ke PDF gratis, Gumroad, atau konsultasi sesuai intent.
Aturan handoff tim Claude Code: bukti review, permission, rollback, dan jalur revenue
Format handoff Claude Code untuk tim: bukti, permission rule, rollback, PDF gratis, Gumroad, dan konsultasi.