Algolia provides hosted full-text search with a JavaScript client and React InstantSearch library — algoliasearch(appId, apiKey) creates a client. index.saveObjects(records) indexes documents with an objectID. <InstantSearch searchClient={client} indexName="products"> wraps search UI. <SearchBox> renders the input. <Hits hitComponent={ProductHit}> renders results. useSearchBox() and useHits() are hook equivalents for headless UI. <RefinementList attribute="brand"> adds faceted filtering. <Configure hitsPerPage={20} filters="inStock:true"> sets search parameters. <Highlight attribute="name" hit={hit}> highlights matching text. index.setSettings({ searchableAttributes, ranking, attributesForFaceting }) configures how results are ranked. Claude Code generates Algolia indexing scripts, React InstantSearch UIs, custom hit components, faceted search, and webhook-based index synchronization patterns.
CLAUDE.md for Algolia
## Algolia Stack
- Version: algoliasearch >= 4.23, react-instantsearch >= 7.11
- Client: const client = algoliasearch(process.env.ALGOLIA_APP_ID, process.env.ALGOLIA_API_KEY)
- Index: const index = client.initIndex("products"); index.saveObjects(records, { autoGenerateObjectIDIfNotExist: true })
- Search UI: <InstantSearch searchClient={sClient} indexName="products"><SearchBox /><Hits hitComponent={Hit} /></InstantSearch>
- Hooks: const { query, refine } = useSearchBox(); const { hits, results } = useHits<ProductHit>()
- Facets: <RefinementList attribute="brand" sortBy={["isRefined", "count"]} />
- Config: <Configure hitsPerPage={20} filters="inStock:true AND price<=100" />
- Highlight: <Highlight attribute="name" hit={hit} /> — wraps matches in <mark>
Index Setup and Management
// lib/algolia/client.ts — Algolia client setup
import algoliasearch from "algoliasearch"
// Admin client (server-side only — has write permissions)
export const algoliaAdmin = algoliasearch(
process.env.ALGOLIA_APP_ID!,
process.env.ALGOLIA_ADMIN_KEY!, // Never expose to client
)
// Search-only client (safe for browser)
export const algoliaSearch = algoliasearch(
process.env.NEXT_PUBLIC_ALGOLIA_APP_ID!,
process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_KEY!, // Read-only key
)
export const productsIndex = algoliaAdmin.initIndex("products")
export const articlesIndex = algoliaAdmin.initIndex("articles")
// lib/algolia/indexing.ts — document indexing and settings
import { productsIndex } from "./client"
// Configure index settings (run once or on config change)
export async function configureProductsIndex() {
await productsIndex.setSettings({
// Which fields to search in, in order of importance
searchableAttributes: [
"name", // Tier 1 — most important
"brand,categories", // Tier 2 — comma = same priority
"description", // Tier 3
"unordered(tags)", // Position in field doesn't matter
],
// Custom ranking — tiebreak after text matching
customRanking: [
"desc(popularity)",
"desc(rating)",
"asc(price)",
],
// Faceting — which attributes to filter by
attributesForFaceting: [
"brand",
"categories",
"searchable(tags)",
"filterOnly(inStock)",
"filterOnly(price)",
],
// Return only these fields to save bandwidth
attributesToRetrieve: [
"objectID",
"name",
"brand",
"price",
"imageUrl",
"slug",
"rating",
"inStock",
],
// Highlight these attributes in results
attributesToHighlight: ["name", "description"],
// Snippet length for description previews
attributesToSnippet: ["description:20"],
// Query rules
queryLanguages: ["en"],
removeStopWords: true,
minWordSizefor1Typo: 4,
minWordSizefor2Typos: 8,
})
console.log("Algolia products index configured")
}
// Index record type
type ProductRecord = {
objectID: string
name: string
brand: string
categories: string[]
tags: string[]
description: string
price: number
imageUrl: string
slug: string
rating: number
reviewCount: number
inStock: boolean
popularity: number
}
// Index a single product
export async function indexProduct(product: ProductRecord) {
return productsIndex.saveObject(product)
}
// Batch index products
export async function indexProducts(products: ProductRecord[]) {
const { objectIDs } = await productsIndex.saveObjects(products)
console.log(`Indexed ${objectIDs.length} products`)
return objectIDs
}
// Delete a product from index
export async function removeProduct(objectID: string) {
return productsIndex.deleteObject(objectID)
}
// Partial update — only update changed fields
export async function updateProductStock(objectID: string, inStock: boolean) {
return productsIndex.partialUpdateObject({ objectID, inStock })
}
React InstantSearch UI
// components/search/ProductSearch.tsx — full search page
"use client"
import {
InstantSearch,
SearchBox,
Hits,
RefinementList,
Pagination,
Configure,
Stats,
SortBy,
ClearRefinements,
CurrentRefinements,
ToggleRefinement,
} from "react-instantsearch"
import { algoliaSearch } from "@/lib/algolia/client"
import { ProductHit } from "./ProductHit"
export function ProductSearch() {
return (
<InstantSearch
searchClient={algoliaSearch}
indexName="products"
future={{ preserveSharedStateOnUnmount: true }}
>
{/* Configure sets default search parameters */}
<Configure hitsPerPage={24} />
<div className="grid grid-cols-[240px_1fr] gap-8">
{/* Sidebar filters */}
<aside className="space-y-6">
<div>
<h3 className="font-semibold mb-2 text-sm">Brand</h3>
<RefinementList
attribute="brand"
sortBy={["isRefined", "count"]}
limit={10}
showMore
showMoreLimit={30}
classNames={{
item: "flex items-center gap-2 py-1",
label: "flex items-center gap-2 text-sm cursor-pointer",
count: "ml-auto text-xs text-muted-foreground",
selectedItem: "font-medium",
}}
/>
</div>
<div>
<h3 className="font-semibold mb-2 text-sm">Category</h3>
<RefinementList attribute="categories" sortBy={["isRefined", "count"]} limit={8} showMore />
</div>
<div>
<ToggleRefinement
attribute="inStock"
label="In stock only"
on={true}
classNames={{
root: "flex items-center gap-2",
label: "text-sm cursor-pointer",
}}
/>
</div>
<ClearRefinements
classNames={{
button: "text-sm text-primary underline hover:no-underline",
disabledButton: "text-sm text-muted-foreground cursor-not-allowed",
}}
/>
</aside>
{/* Results */}
<main className="space-y-4">
<div className="flex items-center gap-4">
<SearchBox
placeholder="Search products..."
classNames={{
root: "flex-1",
form: "relative",
input: "input w-full pr-8",
submit: "absolute right-2 top-1/2 -translate-y-1/2",
reset: "absolute right-8 top-1/2 -translate-y-1/2 text-muted-foreground",
}}
/>
<SortBy
items={[
{ label: "Relevance", value: "products" },
{ label: "Price: Low to High", value: "products_price_asc" },
{ label: "Price: High to Low", value: "products_price_desc" },
{ label: "Top Rated", value: "products_rating_desc" },
]}
classNames={{ select: "input text-sm" }}
/>
</div>
<Stats
classNames={{ root: "text-sm text-muted-foreground" }}
translations={{
rootElementText({ nbHits, processingTimeMS }) {
return `${nbHits.toLocaleString()} results (${processingTimeMS}ms)`
},
}}
/>
<CurrentRefinements
classNames={{
root: "flex flex-wrap gap-2",
item: "flex items-center gap-1 text-xs bg-primary/10 text-primary px-2 py-1 rounded-full",
delete: "ml-1 hover:text-primary/70",
}}
/>
<Hits
hitComponent={ProductHit}
classNames={{
list: "grid grid-cols-3 gap-4",
}}
/>
<Pagination
classNames={{
root: "flex justify-center",
list: "flex gap-1",
item: "w-8 h-8 flex items-center justify-center rounded text-sm",
selectedItem: "bg-primary text-primary-foreground",
}}
/>
</main>
</div>
</InstantSearch>
)
}
Custom Hit Component
// components/search/ProductHit.tsx — typed hit with highlighting
import { Highlight, Snippet } from "react-instantsearch"
import type { Hit } from "instantsearch.js"
import Image from "next/image"
type ProductHitType = Hit<{
name: string
brand: string
price: number
imageUrl: string
slug: string
rating: number
reviewCount: number
description: string
inStock: boolean
}>
export function ProductHit({ hit }: { hit: ProductHitType }) {
return (
<a
href={`/products/${hit.slug}`}
className="group block rounded-xl border bg-card overflow-hidden hover:shadow-md transition-shadow"
>
<div className="relative aspect-square bg-muted">
<Image
src={hit.imageUrl}
alt={hit.name}
fill
className="object-cover group-hover:scale-105 transition-transform duration-300"
sizes="(max-width: 768px) 50vw, 25vw"
/>
{!hit.inStock && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<span className="text-white text-sm font-medium">Out of stock</span>
</div>
)}
</div>
<div className="p-3 space-y-1">
<p className="text-xs text-muted-foreground">{hit.brand}</p>
{/* Highlight wraps matching text in <mark> */}
<h3 className="text-sm font-medium line-clamp-2">
<Highlight attribute="name" hit={hit} classNames={{ highlighted: "bg-yellow-100 dark:bg-yellow-900/30" }} />
</h3>
{/* Snippet shows truncated text with highlights */}
<p className="text-xs text-muted-foreground line-clamp-2">
<Snippet attribute="description" hit={hit} />
</p>
<div className="flex items-center justify-between pt-1">
<span className="font-semibold">${hit.price.toFixed(2)}</span>
{hit.rating > 0 && (
<span className="text-xs text-muted-foreground">★ {hit.rating.toFixed(1)} ({hit.reviewCount})</span>
)}
</div>
</div>
</a>
)
}
Webhook Re-Indexing
// app/api/webhooks/algolia-sync/route.ts — trigger re-index from database events
import { NextRequest, NextResponse } from "next/server"
import { indexProduct, removeProduct } from "@/lib/algolia/indexing"
import { db } from "@/lib/db"
export async function POST(request: NextRequest) {
const { type, record } = await request.json()
// Verify webhook secret
const secret = request.headers.get("x-webhook-secret")
if (secret !== process.env.WEBHOOK_SECRET) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
switch (type) {
case "product.created":
case "product.updated": {
const product = await db.product.findUniqueOrThrow({
where: { id: record.id },
include: { categories: true, tags: true },
})
await indexProduct({
objectID: product.id,
name: product.name,
brand: product.brand,
categories: product.categories.map(c => c.name),
tags: product.tags.map(t => t.name),
description: product.description,
price: product.priceCents / 100,
imageUrl: product.imageUrl,
slug: product.slug,
rating: product.avgRating,
reviewCount: product.reviewCount,
inStock: product.stockQuantity > 0,
popularity: product.viewCount,
})
break
}
case "product.deleted":
await removeProduct(record.id)
break
}
return NextResponse.json({ ok: true })
}
For the Meilisearch alternative when a self-hosted, open-source search engine with a similar API that avoids Algolia’s pricing for high-volume search is preferred — Meilisearch can be deployed on a VPS or used via Meilisearch Cloud, with a JavaScript client and React InstantSearch compatibility layer, see the self-hosted search guide. For the Typesense alternative when another open-source, self-hosted search engine with a focus on typo tolerance, faceting, and vector search (for AI semantic search) alongside keyword search is needed — Typesense is Algolia-compatible with typesense-instantsearch-adapter, see the vector search guide. The Claude Skills 360 bundle includes Algolia skill sets covering indexing, InstantSearch components, and faceted filtering. Start with the free tier to try search UI generation.