Typesense delivers sub-millisecond typo-tolerant search — new Typesense.Client({ nodes: [{ host, port, protocol }], apiKey }) initializes the client. Schema creation: client.collections().create({ name, fields: [{ name, type, facet?, sort? }], default_sorting_field }). Import: client.collections("products").documents().import(docs, { action: "upsert" }). Search: client.collections("products").documents().search({ q: query, query_by: "title,description", filter_by: "price:<100 && inStock:true", sort_by: "price:asc", facet_by: "category,brand", per_page: 20 }). Response: result.hits with [{ document, highlights, text_match }], result.facet_counts for aggregations. Multi-search: client.multiSearch.perform({ searches: [params1, params2] }) runs parallel queries. Geo search: add { name: "_geo", type: "geopoint" } field, filter filter_by: "_geoRadius(lat, lng, radiusMeters)", sort sort_by: "_geoPoint(lat,lng):asc". Curations: client.collections("products").overrides().upsert(id, { rule, includes, excludes }) pins or buries results. typesenseInstantsearchAdapter bridges to react-instantsearch. Claude Code generates Typesense instant search hooks, faceted navigation, and multi-collection federated search.
CLAUDE.md for Typesense
## Typesense Stack
- Version: typesense >= 3.x
- Init: const client = new Typesense.Client({ nodes: [{ host: process.env.TYPESENSE_HOST!, port: 443, protocol: "https" }], apiKey: process.env.TYPESENSE_API_KEY!, connectionTimeoutSeconds: 2 })
- Search: const results = await client.collections("products").documents().search({ q, query_by: "title,description", filter_by: "inStock:true", per_page: 20, facet_by: "category" })
- Hits: results.hits — [{ document: Record<string, unknown>, highlights: [{ field, snippet }], text_match: number }]
- Import docs: await client.collections("products").documents().import(docs, { action: "upsert" })
- Create collection: await client.collections().create(schema) — schema has name, fields[], default_sorting_field
Typesense Client
// lib/typesense/client.ts — Typesense SDK helpers with TypeScript
import Typesense from "typesense"
import type { SearchParams, SearchResponse } from "typesense/lib/Typesense/Documents"
import type { CollectionCreateSchema } from "typesense/lib/Typesense/Collections"
export const tsClient = new Typesense.Client({
nodes: [
{
host: process.env.TYPESENSE_HOST!,
port: 443,
protocol: "https",
},
],
apiKey: process.env.TYPESENSE_API_KEY!,
connectionTimeoutSeconds: 2,
})
// ── Collection management ──────────────────────────────────────────────────
export const PRODUCT_SCHEMA: CollectionCreateSchema = {
name: "products",
fields: [
{ name: "id", type: "string" },
{ name: "title", type: "string" },
{ name: "description", type: "string" },
{ name: "brand", type: "string", facet: true },
{ name: "category", type: "string", facet: true },
{ name: "price", type: "float", facet: true, sort: true },
{ name: "rating", type: "float", sort: true },
{ name: "inStock", type: "bool", facet: true },
{ name: "tags", type: "string[]", facet: true },
{ name: "_geo", type: "geopoint", optional: true },
{ name: "createdAt", type: "int64", sort: true },
],
default_sorting_field: "rating",
}
export async function ensureCollection(schema: CollectionCreateSchema): Promise<void> {
try {
await tsClient.collections(schema.name).retrieve()
} catch {
await tsClient.collections().create(schema)
}
}
export async function dropCollection(name: string): Promise<void> {
await tsClient.collections(name).delete()
}
// ── Document operations ────────────────────────────────────────────────────
export async function upsertDocuments<T extends { id: string }>(
collection: string,
documents: T[],
): Promise<void> {
const BATCH = 1000
for (let i = 0; i < documents.length; i += BATCH) {
const batch = documents.slice(i, i + BATCH)
await tsClient.collections(collection).documents().import(batch, { action: "upsert" })
}
}
export async function deleteDocument(collection: string, id: string): Promise<void> {
await tsClient.collections(collection).documents(id).delete()
}
// ── Search ─────────────────────────────────────────────────────────────────
export type SearchOptions = {
query: string
queryBy: string | string[]
filterBy?: string
sortBy?: string
facetBy?: string | string[]
page?: number
perPage?: number
highlightFields?: string | string[]
numTypos?: number
prefix?: boolean
geoPoint?: { lat: number; lng: number; radiusMeters: number }
}
export type SearchResult<T> = {
hits: Array<{ document: T; highlights: Array<{ field: string; snippet: string; value?: string }>; textMatch: number }>
found: number
page: number
searchTimeMs: number
facetCounts?: Array<{ fieldName: string; counts: Array<{ value: string; count: number }> }>
}
export async function search<T extends Record<string, unknown>>(
collection: string,
options: SearchOptions,
): Promise<SearchResult<T>> {
const {
query,
queryBy,
filterBy,
sortBy,
facetBy,
page = 1,
perPage = 20,
highlightFields,
numTypos,
prefix,
geoPoint,
} = options
const qb = Array.isArray(queryBy) ? queryBy.join(",") : queryBy
const fb = Array.isArray(facetBy) ? facetBy.join(",") : facetBy
const hf = Array.isArray(highlightFields) ? highlightFields.join(",") : highlightFields
let filter = filterBy ?? ""
if (geoPoint) {
const gf = `_geoRadius(${geoPoint.lat}, ${geoPoint.lng}, ${geoPoint.radiusMeters})`
filter = filter ? `${filter} && ${gf}` : gf
}
const params: SearchParams = {
q: query,
query_by: qb,
page,
per_page: perPage,
...(filter ? { filter_by: filter } : {}),
...(sortBy ? { sort_by: sortBy } : {}),
...(fb ? { facet_by: fb } : {}),
...(hf ? { highlight_fields: hf } : {}),
...(numTypos !== undefined ? { num_typos: numTypos } : {}),
...(prefix !== undefined ? { prefix: String(prefix) as "true" | "false" } : {}),
...(geoPoint ? { sort_by: sortBy ?? `_geoPoint(${geoPoint.lat},${geoPoint.lng}):asc` } : {}),
}
const raw = (await tsClient
.collections(collection)
.documents()
.search(params)) as SearchResponse<T>
return {
hits: (raw.hits ?? []).map((h) => ({
document: h.document,
highlights: h.highlights ?? [],
textMatch: h.text_match ?? 0,
})),
found: raw.found,
page: raw.page,
searchTimeMs: raw.search_time_ms,
facetCounts: raw.facet_counts?.map((fc) => ({
fieldName: fc.field_name,
counts: fc.counts.map((c) => ({ value: c.value, count: c.count })),
})),
}
}
/** Multi-search: execute multiple searches in a single request */
export async function multiSearch<T extends Record<string, unknown>>(
searches: Array<{ collection: string; options: SearchOptions }>,
): Promise<SearchResult<T>[]> {
const queries = searches.map(({ collection, options }) => ({
collection,
q: options.query,
query_by: Array.isArray(options.queryBy) ? options.queryBy.join(",") : options.queryBy,
per_page: options.perPage ?? 5,
...(options.filterBy ? { filter_by: options.filterBy } : {}),
}))
const results = await tsClient.multiSearch.perform({ searches: queries }, {})
return (results.results as SearchResponse<T>[]).map((raw) => ({
hits: (raw.hits ?? []).map((h) => ({ document: h.document, highlights: h.highlights ?? [], textMatch: h.text_match ?? 0 })),
found: raw.found,
page: raw.page,
searchTimeMs: raw.search_time_ms,
}))
}
React InstantSearch Adapter
// components/search/TypesenseSearch.tsx — react-instantsearch with Typesense adapter
"use client"
import TypesenseInstantSearchAdapter from "typesense-instantsearch-adapter"
import {
InstantSearch,
SearchBox,
Hits,
RefinementList,
CurrentRefinements,
useInstantSearch,
} from "react-instantsearch"
import type { Hit } from "instantsearch.js"
const adapter = new TypesenseInstantSearchAdapter({
server: {
apiKey: process.env.NEXT_PUBLIC_TYPESENSE_SEARCH_KEY!,
nodes: [{ host: process.env.NEXT_PUBLIC_TYPESENSE_HOST!, port: 443, protocol: "https" }],
},
additionalSearchParameters: {
query_by: "title,description",
highlight_full_fields: "title",
num_typos: 2,
},
})
type ProductDoc = { id: string; title: string; description: string; brand: string; price: number }
function ProductHit({ hit }: { hit: Hit<ProductDoc> }) {
return (
<div className="rounded-lg border p-4">
<h3 className="font-semibold" dangerouslySetInnerHTML={{ __html: hit._highlightResult?.title?.value ?? hit.title }} />
<p className="text-sm text-gray-500 mt-1 line-clamp-2">{hit.description}</p>
<div className="flex justify-between items-center mt-2">
<span className="text-xs text-gray-400">{hit.brand}</span>
<span className="font-bold text-indigo-600">${hit.price}</span>
</div>
</div>
)
}
function Stats() {
const { results } = useInstantSearch()
return results?.nbHits != null ? (
<p className="text-sm text-gray-400">{results.nbHits} results in {results.processingTimeMS}ms</p>
) : null
}
export default function TypesenseSearch() {
return (
<InstantSearch searchClient={adapter.searchClient} indexName="products">
<SearchBox classNames={{ input: "w-full rounded-xl border px-4 py-3 mb-4 text-lg focus:ring-2 focus:ring-indigo-400" }} placeholder="Search…" />
<CurrentRefinements />
<div className="flex gap-6">
<aside className="w-44 shrink-0">
<p className="text-xs uppercase tracking-wide text-gray-400 font-semibold mb-1">Category</p>
<RefinementList attribute="category" />
<p className="text-xs uppercase tracking-wide text-gray-400 font-semibold mt-4 mb-1">Brand</p>
<RefinementList attribute="brand" showMore />
</aside>
<main className="flex-1">
<Stats />
<Hits hitComponent={ProductHit} classNames={{ list: "grid grid-cols-2 lg:grid-cols-3 gap-4 mt-2" }} />
</main>
</div>
</InstantSearch>
)
}
For the Meilisearch alternative when self-hosting open-source search under the SSPL license is acceptable, or preferring Meilisearch’s slightly simpler index settings API and built-in UI dashboard — Meilisearch and Typesense are functionally near-identical with Typesense offering a more permissive GPL-3 license, both use the react-instantsearch adapter, see the Meilisearch guide. For the Algolia alternative when needing a fully-managed cloud search service with AI-powered ranking, A/B testing, and Algolia’s enterprise SLAs with no infrastructure to operate — Algolia is the leading managed service while Typesense is its open-source self-hosted alternative with compatible react-instantsearch components, see the Algolia guide. The Claude Skills 360 bundle includes Typesense skill sets covering instant search, facets, and multi-search. Start with the free tier to try Typesense search generation.