git clone https://github.com/vibeforge1111/vibeship-spawner-skills
integrations/algolia-search/skill.yamlAlgolia Search Integration Skill
Patterns for search API implementation, indexing, and InstantSearch
id: algolia-search name: Algolia Search Integration display_id: algolia-search description: Expert patterns for Algolia search implementation, indexing strategies, React InstantSearch, and relevance tuning version: 1.0.0 category: integrations tags:
- algolia
- search
- instantsearch
- indexing
- relevance
- faceted-search
- autocomplete
triggers:
- "adding search to"
- "algolia"
- "instantsearch"
- "search api"
- "search functionality"
- "typeahead"
- "autocomplete search"
- "faceted search"
- "search index"
- "search as you type"
capabilities:
- "React InstantSearch integration with hooks"
- "Server-side rendering with Next.js"
- "Indexing strategies (full, incremental, partial)"
- "API key security and restrictions"
- "Custom ranking and relevance tuning"
- "Faceted search and filtering"
- "Query suggestions and autocomplete"
- "Multi-index search"
patterns:
-
id: react-instantsearch-setup name: React InstantSearch with Hooks description: | Modern React InstantSearch setup using hooks for type-ahead search.
Uses react-instantsearch-hooks-web package with algoliasearch client. Widgets are components that can be customized with classnames.
Key hooks:
- useSearchBox: Search input handling
- useHits: Access search results
- useRefinementList: Facet filtering
- usePagination: Result pagination
- useInstantSearch: Full state access
code_example: | // lib/algolia.ts import algoliasearch from 'algoliasearch/lite';
export const searchClient = algoliasearch( process.env.NEXT_PUBLIC_ALGOLIA_APP_ID!, process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_KEY! // Search-only key! );
export const INDEX_NAME = 'products';
// components/Search.tsx 'use client'; import { InstantSearch, SearchBox, Hits, Configure } from 'react-instantsearch'; import { searchClient, INDEX_NAME } from '@/lib/algolia';
function Hit({ hit }: { hit: ProductHit }) { return ( <article> <h3>{hit.name}</h3> <p>{hit.description}</p> <span>${hit.price}</span> </article> ); }
export function ProductSearch() { return ( <InstantSearch searchClient={searchClient} indexName={INDEX_NAME}> <Configure hitsPerPage={20} /> <SearchBox placeholder="Search products..." classNames={{ root: 'relative', input: 'w-full px-4 py-2 border rounded', }} /> <Hits hitComponent={Hit} /> </InstantSearch> ); }
// Custom hook usage import { useSearchBox, useHits, useInstantSearch } from 'react-instantsearch';
function CustomSearch() { const { query, refine } = useSearchBox(); const { hits } = useHits<ProductHit>(); const { status } = useInstantSearch();
return ( <div> <input value={query} onChange={(e) => refine(e.target.value)} placeholder="Search..." /> {status === 'loading' && <p>Loading...</p>} <ul> {hits.map((hit) => ( <li key={hit.objectID}>{hit.name}</li> ))} </ul> </div> );}
anti_patterns:
-
pattern: "Using Admin API key in frontend code" why: "Admin key exposes full index control including deletion" fix: "Use search-only API key with restrictions"
-
pattern: "Not using /lite client for frontend" why: "Full client includes unnecessary code for search" fix: "Import from algoliasearch/lite for smaller bundle"
references:
-
id: nextjs-ssr-search name: Next.js Server-Side Rendering description: | SSR integration for Next.js with react-instantsearch-nextjs package.
Use <InstantSearchNext> instead of <InstantSearch> for SSR. Supports both Pages Router and App Router (experimental).
Key considerations:
- Set dynamic = 'force-dynamic' for fresh results
- Handle URL synchronization with routing prop
- Use getServerState for initial state
code_example: | // app/search/page.tsx import { InstantSearchNext } from 'react-instantsearch-nextjs'; import { searchClient, INDEX_NAME } from '@/lib/algolia'; import { SearchBox, Hits, RefinementList } from 'react-instantsearch';
// Force dynamic rendering for fresh search results export const dynamic = 'force-dynamic';
export default function SearchPage() { return ( <InstantSearchNext searchClient={searchClient} indexName={INDEX_NAME} routing={{ router: { cleanUrlOnDispose: false, }, }} > <div className="flex gap-8"> <aside className="w-64"> <h3>Categories</h3> <RefinementList attribute="category" /> <h3>Brand</h3> <RefinementList attribute="brand" /> </aside> <main className="flex-1"> <SearchBox placeholder="Search products..." /> <Hits hitComponent={ProductHit} /> </main> </div> </InstantSearchNext> ); }
// For custom routing (URL synchronization) import { history } from 'instantsearch.js/es/lib/routers'; import { simple } from 'instantsearch.js/es/lib/stateMappings';
<InstantSearchNext searchClient={searchClient} indexName={INDEX_NAME} routing={{ router: history({ getLocation: () => typeof window === 'undefined' ? new URL(url) as unknown as Location : window.location, }), stateMapping: simple(), }}
</InstantSearchNext>{/* widgets */}anti_patterns:
-
pattern: "Using InstantSearch component for Next.js SSR" why: "Regular component doesn't support server-side rendering" fix: "Use InstantSearchNext from react-instantsearch-nextjs"
-
pattern: "Static rendering for search pages" why: "Search results must be fresh for each request" fix: "Set export const dynamic = 'force-dynamic'"
references:
-
id: indexing-strategies name: Data Synchronization and Indexing description: | Indexing strategies for keeping Algolia in sync with your data.
Three main approaches:
- Full Reindexing - Replace entire index (expensive)
- Full Record Updates - Replace individual records
- Partial Updates - Update specific attributes only
Best practices:
- Batch records (ideal: 10MB, 1K-10K records per batch)
- Use incremental updates when possible
- partialUpdateObjects for attribute-only changes
- Avoid deleteBy (computationally expensive)
code_example: | // lib/algolia-admin.ts (SERVER ONLY) import algoliasearch from 'algoliasearch';
// Admin client - NEVER expose to frontend const adminClient = algoliasearch( process.env.ALGOLIA_APP_ID!, process.env.ALGOLIA_ADMIN_KEY! // Admin key for indexing );
const index = adminClient.initIndex('products');
// Batch indexing (recommended approach) export async function indexProducts(products: Product[]) { const records = products.map((p) => ({ objectID: p.id, // Required unique identifier name: p.name, description: p.description, price: p.price, category: p.category, inStock: p.inventory > 0, createdAt: p.createdAt.getTime(), // Use timestamps for sorting }));
// Batch in chunks of ~1000-5000 records const BATCH_SIZE = 1000; for (let i = 0; i < records.length; i += BATCH_SIZE) { const batch = records.slice(i, i + BATCH_SIZE); await index.saveObjects(batch); }}
// Partial update - update only specific fields export async function updateProductPrice(productId: string, price: number) { await index.partialUpdateObject({ objectID: productId, price, updatedAt: Date.now(), }); }
// Partial update with operations export async function incrementViewCount(productId: string) { await index.partialUpdateObject({ objectID: productId, viewCount: { _operation: 'Increment', value: 1, }, }); }
// Delete records (prefer this over deleteBy) export async function deleteProducts(productIds: string[]) { await index.deleteObjects(productIds); }
// Full reindex with zero-downtime (atomic swap) export async function fullReindex(products: Product[]) { const tempIndex = adminClient.initIndex('products_temp');
// Index to temp index await tempIndex.saveObjects( products.map((p) => ({ objectID: p.id, ...p, })) ); // Copy settings from main index await adminClient.copyIndex('products', 'products_temp', { scope: ['settings', 'synonyms', 'rules'], }); // Atomic swap await adminClient.moveIndex('products_temp', 'products');}
anti_patterns:
-
pattern: "Using deleteBy for bulk deletions" why: "deleteBy is computationally expensive and rate limited" fix: "Use deleteObjects with array of objectIDs"
-
pattern: "Indexing one record at a time" why: "Creates indexing queue, slows down process" fix: "Batch records in groups of 1K-10K"
-
pattern: "Full reindex for small changes" why: "Wastes operations, slower than incremental" fix: "Use partialUpdateObject for attribute changes"
references:
-
id: api-key-security name: API Key Security and Restrictions description: | Secure API key configuration for Algolia.
Key types:
- Admin API Key: Full control (indexing, settings, deletion)
- Search-Only API Key: Safe for frontend
- Secured API Keys: Generated from base key with restrictions
Restrictions available:
- Indices: Limit accessible indices
- Rate limit: Limit API calls per hour per IP
- Validity: Set expiration time
- HTTP referrers: Restrict to specific URLs
- Query parameters: Enforce search parameters
code_example: | // NEVER do this - admin key in frontend // const client = algoliasearch(appId, ADMIN_KEY); // WRONG!
// Correct: Use search-only key in frontend const searchClient = algoliasearch( process.env.NEXT_PUBLIC_ALGOLIA_APP_ID!, process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_KEY! );
// Server-side: Generate secured API key // lib/algolia-secured-key.ts import algoliasearch from 'algoliasearch';
const adminClient = algoliasearch( process.env.ALGOLIA_APP_ID!, process.env.ALGOLIA_ADMIN_KEY! );
// Generate user-specific secured key export function generateSecuredKey(userId: string) { const searchKey = process.env.ALGOLIA_SEARCH_KEY!;
return adminClient.generateSecuredApiKey(searchKey, { // User can only see their own data filters: `userId:${userId}`, // Key expires in 1 hour validUntil: Math.floor(Date.now() / 1000) + 3600, // Restrict to specific index restrictIndices: ['user_documents'], });}
// Rate-limited key for public APIs export async function createRateLimitedKey() { const { key } = await adminClient.addApiKey({ acl: ['search'], indexes: ['products'], description: 'Public search with rate limit', maxQueriesPerIPPerHour: 1000, referers: ['https://mysite.com/*'], validity: 0, // Never expires });
return key;}
// API endpoint to get user's secured key // app/api/search-key/route.ts import { auth } from '@/lib/auth'; import { generateSecuredKey } from '@/lib/algolia-secured-key';
export async function GET() { const session = await auth(); if (!session?.user) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); }
const securedKey = generateSecuredKey(session.user.id); return Response.json({ key: securedKey });}
anti_patterns:
-
pattern: "Hardcoding Admin API key in client code" why: "Exposes full index control to attackers" fix: "Use search-only key with restrictions"
-
pattern: "Using same key for all users" why: "Can't restrict data access per user" fix: "Generate secured API keys with user filters"
-
pattern: "No rate limiting on public search" why: "Bots can exhaust your search quota" fix: "Set maxQueriesPerIPPerHour on API key"
references:
-
id: custom-ranking-relevance name: Custom Ranking and Relevance Tuning description: | Configure searchable attributes and custom ranking for relevance.
Searchable attributes (order matters):
- Most important fields first (title, name)
- Secondary fields next (description, tags)
- Exclude non-searchable fields (image_url, id)
Custom ranking:
- Add business metrics (popularity, rating, date)
- Use desc() for descending, asc() for ascending
code_example: | // scripts/configure-index.ts import algoliasearch from 'algoliasearch';
const adminClient = algoliasearch( process.env.ALGOLIA_APP_ID!, process.env.ALGOLIA_ADMIN_KEY! );
const index = adminClient.initIndex('products');
async function configureIndex() { await index.setSettings({ // Searchable attributes in order of importance searchableAttributes: [ 'name', // Most important 'brand', 'category', 'description', // Least important ],
// Attributes for faceting/filtering attributesForFaceting: [ 'category', 'brand', 'filterOnly(inStock)', // Filter only, not displayed 'searchable(tags)', // Searchable facet ], // Custom ranking (after text relevance) customRanking: [ 'desc(popularity)', // Most popular first 'desc(rating)', // Then by rating 'desc(createdAt)', // Then by recency ], // Typo tolerance typoTolerance: true, minWordSizefor1Typo: 4, minWordSizefor2Typos: 8, // Query settings queryLanguages: ['en'], removeStopWords: ['en'], // Highlighting attributesToHighlight: ['name', 'description'], highlightPreTag: '<mark>', highlightPostTag: '</mark>', // Pagination hitsPerPage: 20, paginationLimitedTo: 1000, // Distinct (deduplication) attributeForDistinct: 'productFamily', distinct: true, }); // Add synonyms await index.saveSynonyms([ { objectID: 'phone-mobile', type: 'synonym', synonyms: ['phone', 'mobile', 'cell', 'smartphone'], }, { objectID: 'laptop-notebook', type: 'oneWaySynonym', input: 'laptop', synonyms: ['notebook', 'portable computer'], }, ]); // Add rules (query-based customization) await index.saveRules([ { objectID: 'boost-sale-items', condition: { anchoring: 'contains', pattern: 'sale', }, consequence: { params: { filters: 'onSale:true', optionalFilters: ['featured:true'], }, }, }, ]); console.log('Index configured successfully');}
configureIndex();
anti_patterns:
-
pattern: "Searching all attributes equally" why: "Reduces relevance, matches in descriptions rank same as titles" fix: "Order searchableAttributes by importance"
-
pattern: "No custom ranking" why: "Relies only on text matching, ignores business value" fix: "Add popularity, rating, or recency to customRanking"
-
pattern: "Indexing raw dates as strings" why: "Can't sort by date correctly" fix: "Use timestamps (getTime()) for date sorting"
references:
-
id: faceted-search name: Faceted Search and Filtering description: | Implement faceted navigation with refinement lists, range sliders, and hierarchical menus.
Widget types:
- RefinementList: Multi-select checkboxes
- Menu: Single-select list
- HierarchicalMenu: Nested categories
- RangeInput/RangeSlider: Numeric ranges
- ToggleRefinement: Boolean filters
code_example: | 'use client'; import { InstantSearch, SearchBox, Hits, RefinementList, HierarchicalMenu, RangeInput, ToggleRefinement, ClearRefinements, CurrentRefinements, Stats, SortBy, } from 'react-instantsearch'; import { searchClient, INDEX_NAME } from '@/lib/algolia';
export function ProductSearch() { return ( <InstantSearch searchClient={searchClient} indexName={INDEX_NAME}> <div className="flex gap-8"> {/* Filters Sidebar */} <aside className="w-64 space-y-6"> <ClearRefinements /> <CurrentRefinements />
{/* Category hierarchy */} <div> <h3 className="font-semibold mb-2">Categories</h3> <HierarchicalMenu attributes={[ 'categories.lvl0', 'categories.lvl1', 'categories.lvl2', ]} limit={10} showMore /> </div> {/* Brand filter */} <div> <h3 className="font-semibold mb-2">Brand</h3> <RefinementList attribute="brand" searchable searchablePlaceholder="Search brands..." showMore limit={5} showMoreLimit={20} /> </div> {/* Price range */} <div> <h3 className="font-semibold mb-2">Price</h3> <RangeInput attribute="price" precision={0} classNames={{ input: 'w-20 px-2 py-1 border rounded', }} /> </div> {/* In stock toggle */} <ToggleRefinement attribute="inStock" label="In Stock Only" on={true} /> {/* Rating filter */} <div> <h3 className="font-semibold mb-2">Rating</h3> <RefinementList attribute="rating" transformItems={(items) => items.map((item) => ({ ...item, label: '★'.repeat(Number(item.label)), })) } /> </div> </aside> {/* Results */} <main className="flex-1"> <div className="flex justify-between items-center mb-4"> <SearchBox placeholder="Search products..." /> <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: 'Rating', value: 'products_rating_desc' }, ]} /> </div> <Stats /> <Hits hitComponent={ProductHit} /> </main> </div> </InstantSearch> );}
// For sorting, create replica indices // products_price_asc: customRanking: ['asc(price)'] // products_price_desc: customRanking: ['desc(price)'] // products_rating_desc: customRanking: ['desc(rating)']
anti_patterns:
-
pattern: "Faceting on non-faceted attributes" why: "Must declare attributesForFaceting in settings" fix: "Add attributes to attributesForFaceting array"
-
pattern: "Not using filterOnly() for hidden filters" why: "Wastes facet computation on non-displayed attributes" fix: "Use filterOnly(attribute) for filters you won't show"
references:
-
id: autocomplete-suggestions name: Query Suggestions and Autocomplete description: | Implement autocomplete with query suggestions and instant results.
Uses @algolia/autocomplete-js for standalone autocomplete or integrate with InstantSearch using SearchBox.
Query Suggestions require a separate index generated by Algolia.
code_example: | // Standalone Autocomplete // components/Autocomplete.tsx 'use client'; import { autocomplete, getAlgoliaResults } from '@algolia/autocomplete-js'; import algoliasearch from 'algoliasearch/lite'; import { useEffect, useRef } from 'react'; import '@algolia/autocomplete-theme-classic';
const searchClient = algoliasearch( process.env.NEXT_PUBLIC_ALGOLIA_APP_ID!, process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_KEY! );
export function Autocomplete() { const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => { if (!containerRef.current) return; const search = autocomplete({ container: containerRef.current, placeholder: 'Search for products', openOnFocus: true, getSources({ query }) { if (!query) return []; return [ // Query suggestions { sourceId: 'suggestions', getItems() { return getAlgoliaResults({ searchClient, queries: [ { indexName: 'products_query_suggestions', query, params: { hitsPerPage: 5 }, }, ], }); }, templates: { header() { return 'Suggestions'; }, item({ item, html }) { return html`<span>${item.query}</span>`; }, }, }, // Instant results { sourceId: 'products', getItems() { return getAlgoliaResults({ searchClient, queries: [ { indexName: 'products', query, params: { hitsPerPage: 8 }, }, ], }); }, templates: { header() { return 'Products'; }, item({ item, html }) { return html` <a href="/products/${item.objectID}"> <img src="${item.image}" alt="${item.name}" /> <span>${item.name}</span> <span>$${item.price}</span> </a> `; }, }, onSelect({ item, setQuery, refresh }) { // Navigate on selection window.location.href = `/products/${item.objectID}`; }, }, ]; }, }); return () => search.destroy(); }, []); return <div ref={containerRef} />;}
// Combined with InstantSearch import { connectSearchBox } from 'react-instantsearch'; import { autocomplete } from '@algolia/autocomplete-js';
// Or use built-in Autocomplete widget import { Autocomplete as AlgoliaAutocomplete } from 'react-instantsearch';
export function SearchWithAutocomplete() { return ( <InstantSearch searchClient={searchClient} indexName="products"> <AlgoliaAutocomplete placeholder="Search products..." detachedMediaQuery="(max-width: 768px)" /> <Hits hitComponent={ProductHit} /> </InstantSearch> ); }
anti_patterns:
-
pattern: "Creating autocomplete without debouncing" why: "Every keystroke triggers search, wastes operations" fix: "Algolia autocomplete handles debouncing automatically"
-
pattern: "Not using Query Suggestions index" why: "Missing search analytics for popular queries" fix: "Enable Query Suggestions in Algolia dashboard"
references:
-
handoff_triggers:
-
condition: "needs e-commerce checkout" target_skill: stripe-integration context: "Product search leading to purchase flow"
-
condition: "needs analytics" target_skill: segment-cdp context: "Search event tracking"
-
condition: "needs database" target_skill: postgres-wizard context: "Source data for indexing"