Skip to main content
โšก Calmops

Meilisearch in Production: Real-World Patterns and Best Practices

Introduction

Meilisearch has proven itself in production environments across industries. Its simplicity, speed, and relevance make it an excellent choice for various applications. This article explores real-world use cases, implementation patterns, and best practices learned from deploying Meilisearch at scale.

We will examine production scenarios ranging from e-commerce platforms to documentation search, mobile applications to multi-tenant SaaS systems. Each use case includes implementation details and code examples you can adapt for your own projects.

E-commerce search is one of the most demanding search applications. Users expect instant, relevant results with sophisticated filtering and sorting.

Key Requirements

E-commerce search requires:

  • Instant search-as-you-type
  • Faceted filtering (category, price, brand)
  • Typo tolerance for product names
  • Relevance tuning by popularity and sales
  • Availability tracking
  • Price-based sorting

Implementation

// Initialize Meilisearch for e-commerce
const { MeiliSearch } = require('meilisearch')

const client = new MeiliSearch({
  host: process.env.MEILI_HOST,
  apiKey: process.env.MEILI_MASTER_KEY
})

const productsIndex = client.index('products')

// Configure settings for e-commerce
async function configureProductIndex() {
  await productsIndex.updateSettings({
    searchableAttributes: [
      'title^3',        // High priority
      'brand^2',
      'description',
      'category_path',
      'tags'
    ],
    filterableAttributes: [
      'category_id',
      'brand_id',
      'price',
      'in_stock',
      'rating',
      'attributes'
    ],
    sortableAttributes: [
      'price',
      'rating',
      'sales_count',
      'created_at'
    ],
    typoTolerance: {
      enabled: true,
      minWordSizeForTypos: {
        oneTypo: 4,
        twoTypos: 8
      }
    }
  })
}

// Search with facets
async function searchProducts(query, filters, page = 1) {
  const results = await productsIndex.search(query, {
    filter: buildFilterString(filters),
    facets: ['category_id', 'brand_id', 'in_stock'],
    sort: [filters.sort || 'sales_count:desc'],
    limit: 24,
    page: page,
    hitsPerPage: 24,
    attributesToHighlight: ['title', 'description'],
    attributesToRetrieve: [
      'id', 'title', 'price', 'original_price',
      'image_url', 'brand', 'rating', 'in_stock'
    ]
  })
  
  return results
}

// Build filter string from user selections
function buildFilterString(filters) {
  const filterParts = []
  
  if (filters.category) {
    filterParts.push(`category_id = ${filters.category}`)
  }
  
  if (filters.brand) {
    filterParts.push(`brand_id IN [${filters.brand.join(',')}]`)
  }
  
  if (filters.minPrice) {
    filterParts.push(`price >= ${filters.minPrice}`)
  }
  
  if (filters.maxPrice) {
    filterParts.push(`price <= ${filters.maxPrice}`)
  }
  
  if (filters.inStock) {
    filterParts.push('in_stock = true')
  }
  
  return filterParts.join(' AND ')
}
async function getAutocompleteSuggestions(query) {
  // Get product matches
  const products = await productsIndex.search(query, {
    limit: 5,
    attributesToRetrieve: ['id', 'title', 'image_url', 'price']
  })
  
  // Get brand matches
  const brands = await brandsIndex.search(query, {
    limit: 3,
    attributesToRetrieve: ['id', 'name', 'logo']
  })
  
  // Get category matches
  const categories = await categoriesIndex.search(query, {
    limit: 3,
    attributesToRetrieve: ['id', 'name']
  })
  
  return {
    products: products.hits,
    brands: brands.hits,
    categories: categories.hits,
    suggestions: generateQuerySuggestions(query)
  }
}

Personalization

async function personalizedSearch(userId, query, browsingHistory) {
  // Boost user's preferred brands
  const preferredBrands = await getUserBrandPreferences(userId)
  
  const boostQuery = preferredBrands.length > 0
    ? `${query} ${preferredBrands.join(' ')}`
    : query
  
  const results = await productsIndex.search(boostQuery, {
    filter: browsingHistory.excludedCategories 
      ? `NOT category_id IN [${browsingHistory.excludedCategories.join(',')}]`
      : undefined,
    limit: 24,
    attributesToRetrieve: ['*']
  })
  
  return results
}

Documentation sites require different search capabilities, including code snippet handling and hierarchical content.

Key Requirements

Documentation search needs:

  • Full-text search across code and prose
  • Section-level matching
  • Typo tolerance for technical terms
  • Version-aware results
  • Instant feedback

Implementation

const docsIndex = client.index('documentation')

async function configureDocsIndex() {
  await docsIndex.updateSettings({
    searchableAttributes: [
      'title^3',
      'headings^2',
      'content',
      'code_examples',
      'tags'
    ],
    filterableAttributes: [
      'version',
      'category',
      'language',
      'product'
    ],
    sortableAttributes: ['title', 'last_updated'],
    typoTolerance: {
      enabled: true,
      // Allow more typos for technical terms
      minWordSizeForTypos: {
        oneTypo: 3,
        twoTypos: 6
      }
    },
    // Prioritize exact matches in headings
    rankingRules: [
      'words',
      'typo',
      'proximity',
      'attribute',
      'exactness',
      'sort'
    ]
  })
}

Document Structure

// Index documentation with proper structure
const docStructure = {
  id: 'getting-started-installation',
  title: 'Installation',
  product: 'api',
  version: '2.0',
  category: 'getting-started',
  language: 'en',
  headings: [
    'Installation',
    'Requirements',
    'Quick Start',
    'Next Steps'
  ],
  content: `## Installation
  
  Install the SDK using npm:
  
  \`\`\`bash
  npm install @example/api-client
  \`\`\`
  
  ## Requirements
  
  - Node.js 18+
  - npm or yarn`,
  code_examples: [
    'npm install @example/api-client',
    'const client = new APIClient({ apiKey: "..." })'
  ],
  last_updated: '2026-01-15',
  slug: '/docs/getting-started/installation'
}
async function searchDocumentation(query, version) {
  const results = await docsIndex.search(query, {
    filter: version ? `version = "${version}"` : undefined,
    limit: 10,
    attributesToHighlight: ['content', 'title'],
    attributesToRetrieve: [
      'id', 'title', 'slug', 'version', 
      'category', 'last_updated'
    ],
    // Highlight matching sections
    showMatchesPosition: true
  })
  
  // Group by category
  const groupedResults = groupBy(results.hits, 'category')
  
  return groupedResults
}

Mobile search requires special considerations for limited screen space, offline capabilities, and touch interfaces.

Key Requirements

Mobile search needs:

  • Compact result display
  • Offline search capability
  • Voice search support
  • Recent searches
  • Location-based results

Implementation

// React Native example
import { MeiliSearch } from 'meilisearch'

const client = new MeiliSearch({
  host: 'https://your-app.meilisearch.cloud',
  apiKey: 'your_mobile_api_key'
})

// Local cache for offline search
const AsyncStorage = require('@react-native-async-storage/asyncStorage')

class MobileSearchService {
  constructor() {
    this.recentSearches = []
    this.loadRecentSearches()
  }
  
  async loadRecentSearches() {
    const stored = await AsyncStorage.getItem('recentSearches')
    if (stored) {
      this.recentSearches = JSON.parse(stored)
    }
  }
  
  async saveRecentSearch(query) {
    this.recentSearches = [
      query,
      ...this.recentSearches.filter(q => q !== query)
    ].slice(0, 10)
    
    await AsyncStorage.setItem(
      'recentSearches', 
      JSON.stringify(this.recentSearches)
    )
  }
  
  async search(query) {
    // Save to recent searches
    await this.saveRecentSearch(query)
    
    const results = await client.index('products').search(query, {
      limit: 20,
      attributesToRetrieve: [
        'id', 'title', 'price', 'image_url', 'category'
      ],
      // Smaller payload for mobile
      attributesToHighlight: ['title']
    })
    
    return {
      hits: results.hits,
      query: results.query,
      processingTime: results.processingTimeMs,
      // For infinite scroll
      hasMore: results.estimatedTotalHits > 20
    }
  }
}

export const searchService = new MobileSearchService()

Voice Search Integration

// Using Web Speech API for voice search
class VoiceSearch {
  constructor(onResult) {
    this.recognition = new (window.SpeechRecognition || 
                          window.webkitSpeechRecognition)()
    this.recognition.continuous = false
    this.recognition.interimResults = false
    
    this.recognition.onresult = (event) => {
      const transcript = event.results[0][0].transcript
      onResult(transcript)
    }
  }
  
  start() {
    this.recognition.start()
  }
  
  stop() {
    this.recognition.stop()
  }
}

// Usage in React component
function SearchScreen() {
  const [query, setQuery] = useState('')
  
  const handleVoiceSearch = (transcript) => {
    setQuery(transcript)
    performSearch(transcript)
  }
  
  return (
    <View>
      <SearchBar 
        value={query}
        onChangeText={setQuery}
        onVoicePress={() => new VoiceSearch(handleVoiceSearch).start()}
      />
      <SearchResults />
    </View>
  )
}

Multi-Tenant Applications

Multi-tenant search requires strict data isolation while maintaining efficient resource usage.

Key Requirements

Multi-tenant search needs:

  • Tenant isolation
  • Resource sharing efficiency
  • Custom relevance per tenant
  • Tenant-specific dictionaries

Implementation with Tenant Tokens

const { MeiliSearch } = require('meilisearch')

// Generate tenant-specific tokens server-side
function generateTenantToken(tenantId, permissions) {
  const client = new MeiliSearch({
    host: process.env.MEILI_HOST,
    apiKey: process.env.MEILI_MASTER_KEY
  })
  
  const apiKey = client.generateTenantToken(
    tenantId,
    permissions,
    { expiresIn: '24h' }
  )
  
  return apiKey
}

// Client uses tenant-specific key
function getClientForTenant(tenantId, tenantApiKey) {
  return new MeiliSearch({
    host: process.env.MEILI_HOST,
    apiKey: tenantApiKey
  })
}

Per-Tenant Index Configuration

// Manage multiple tenants with shared infrastructure
class MultiTenantManager {
  constructor() {
    this.client = new MeiliSearch({
      host: process.env.MEILI_HOST,
      apiKey: process.env.MEILI_MASTER_KEY
    })
  }
  
  async createTenantIndex(tenantId, config) {
    const indexName = `tenant_${tenantId}`
    
    // Create index
    await this.client.createIndex(indexName, { 
      primaryKey: 'id' 
    })
    
    const index = this.client.index(indexName)
    
    // Apply tenant-specific configuration
    await index.updateSettings({
      searchableAttributes: config.searchableAttributes,
      filterableAttributes: config.filterableAttributes,
      sortableAttributes: config.sortableAttributes,
      typoTolerance: config.typoTolerance || {
        enabled: true,
        minWordSizeForTypos: { oneTypo: 4, twoTypos: 8 }
      }
    })
    
    return indexName
  }
  
  async searchAsTenant(tenantId, query, options) {
    const index = this.client.index(`tenant_${tenantId}`)
    return await index.search(query, options)
  }
}

Data Isolation Strategies

// Option 1: Separate indexes per tenant
// Pros: Complete isolation, easy cleanup
// Cons: More resources, less efficient

// Option 2: Single index with tenant field
// Pros: Efficient resource usage
// Cons: Requires careful filter management

async function searchWithTenantFilter(tenantId, query) {
  const index = client.index('shared_index')
  
  return await index.search(query, {
    // Always filter by tenant
    filter: `tenant_id = "${tenantId}"`,
    limit: 20
  })
}

Location-aware search is essential for applications like store finders, delivery apps, and real estate listings.

Key Requirements

Geo-search needs:

  • Distance-based sorting
  • Radius filtering
  • Multi-location support
  • Map integration

Implementation

While Meilisearch doesn’t have native geo capabilities, you can implement geo-search using a hybrid approach:

// Store coordinates as arrays for distance calculations
const locationIndex = client.index('locations')

await locationIndex.updateSettings({
  searchableAttributes: ['name', 'address', 'description'],
  filterableAttributes: ['category', 'city', 'country'],
  sortableAttributes: ['name', 'rating']
})

// Index locations with coordinates
const locations = [
  {
    id: 'store-1',
    name: 'Downtown Branch',
    address: '123 Main St, New York, NY',
    city: 'New York',
    country: 'USA',
    coordinates: [40.7128, -74.0060], // [lat, lon]
    category: 'retail'
  },
  {
    id: 'store-2',
    name: 'Brooklyn Branch',
    address: '456 Atlantic Ave, Brooklyn, NY',
    city: 'Brooklyn',
    country: 'USA',
    coordinates: [40.6782, -73.9442],
    category: 'retail'
  }
]

await locationIndex.add_documents(locations)

// Haversine distance calculation (for sorting)
function calculateDistance(lat1, lon1, lat2, lon2) {
  const R = 6371 // Earth's radius in km
  const dLat = toRad(lat2 - lat1)
  const dLon = toRad(lon2 - lon1)
  const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
            Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
            Math.sin(dLon/2) * Math.sin(dLon/2)
  const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a))
  return R * c
}

function toRad(deg) {
  return deg * (Math.PI/180)
}

// Search with geo-filtering (client-side radius)
async function searchNearby(query, lat, lon, radiusKm) {
  // Get more results than needed
  const results = await locationIndex.search(query, {
    filter: 'category = "retail"',
    limit: 50,
    attributesToRetrieve: ['*']
  })
  
  // Filter by distance client-side
  const nearby = results.hits
    .filter(hit => {
      const [hitLat, hitLon] = hit.coordinates
      const distance = calculateDistance(lat, lon, hitLat, hitLon)
      hit.distance = distance
      return distance <= radiusKm
    })
    .sort((a, b) => a.distance - b.distance)
    .slice(0, 20)
  
  return nearby
}

Inventory search requires real-time updates and availability filtering.

Implementation

const inventoryIndex = client.index('inventory')

// Configure for real-time updates
async function configureInventorySearch() {
  await inventoryIndex.updateSettings({
    searchableAttributes: [
      'sku^3',
      'product_name^2',
      'description',
      'brand',
      'tags'
    ],
    filterableAttributes: [
      'warehouse_id',
      'quantity',
      'status',
      'category',
      'price'
    ],
    typoTolerance: {
      enabled: true
    }
  })
}

// Real-time inventory updates
async function updateInventory(sku, quantity, warehouseId) {
  const task = await inventoryIndex.updateDocuments([{
    id: sku,
    quantity: quantity,
    status: quantity > 0 ? 'available' : 'out_of_stock',
    warehouse_id: warehouseId,
    last_updated: new Date().toISOString()
  }])
  
  return task
}

// Search with availability filter
async function searchAvailableProducts(query) {
  return await inventoryIndex.search(query, {
    filter: 'quantity > 0 AND status = "available"',
    sort: ['quantity:desc'],
    limit: 50,
    attributesToRetrieve: [
      'id', 'sku', 'product_name', 'quantity', 
      'price', 'warehouse_id'
    ]
  })
}

Best Practices for Production

These patterns ensure reliable production deployments.

Error Handling

class MeilisearchService {
  constructor(client) {
    this.client = client
    this.maxRetries = 3
    this.retryDelay = 1000
  }
  
  async searchWithRetry(indexName, query, options = {}, attempt = 1) {
    try {
      const index = this.client.index(indexName)
      return await index.search(query, options)
    } catch (error) {
      if (attempt >= this.maxRetries) {
        throw error
      }
      
      // Exponential backoff
      await new Promise(r => setTimeout(r, this.retryDelay * attempt))
      
      return this.searchWithRetry(indexName, query, options, attempt + 1)
    }
  }
  
  isRetryableError(error) {
    const retryableCodes = ['code', 'internal', 'timeout']
    return error.code && retryableCodes.includes(error.code)
  }
}

Rate Limiting

const rateLimit = require('express-rate-limit')

const searchLimiter = rateLimit({
  windowMs: 60 * 1000, // 1 minute
  max: 100, // 100 requests per minute
  message: { error: 'Too many searches, please try again later' }
})

app.use('/api/search', searchLimiter)

Monitoring

const promClient = require('prom-client')

const searchDuration = new promClient.Histogram({
  name: 'meilisearch_query_duration_ms',
  help: 'Duration of Meilisearch queries in milliseconds',
  labelNames: ['index', 'result_count'],
  buckets: [10, 25, 50, 100, 250, 500]
})

async function monitoredSearch(indexName, query) {
  const start = process.hrtime.bigint()
  
  try {
    const results = await client.index(indexName).search(query)
    
    const duration = Number(process.hrtime.bigint() - start) / 1e6
    
    searchDuration.labels({
      index: indexName,
      result_count: results.estimatedTotalHits
    }).observe(duration)
    
    return results
  } catch (error) {
    // Log error
    console.error('Search error:', error)
    throw error
  }
}

Performance Optimization

// Optimize for large result sets
async function optimizedSearch(query, pagination) {
  const index = client.index('products')
  
  // Use offset-based pagination for better performance
  const offset = (pagination.page - 1) * pagination.limit
  
  return await index.search(query, {
    limit: pagination.limit,
    offset: offset,
    // Only retrieve needed attributes
    attributesToRetrieve: ['id', 'title', 'price', 'image_url'],
    attributesToHighlight: ['title'],
    // Request facets for filter UI
    facets: ['category', 'brand'],
    // Use cold start optimization
    filter: 'status = "active"'
  })
}

// Implement caching for frequent queries
const queryCache = new Map()
const CACHE_TTL = 60000 // 1 minute

async function cachedSearch(query) {
  const cached = queryCache.get(query)
  
  if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
    return cached.results
  }
  
  const results = await client.index('products').search(query)
  
  queryCache.set(query, {
    results,
    timestamp: Date.now()
  })
  
  return results
}

Scaling Patterns

Handle growth with these strategies.

Horizontal Scaling

// Client-side load balancing
class MeilisearchPool {
  constructor(endpoints) {
    this.endpoints = endpoints.map(url => ({
      url,
      client: new MeiliSearch({ host: url }),
      requests: 0
    }))
    this.current = 0
  }
  
  getClient() {
    const client = this.endpoints[this.current]
    this.current = (this.current + 1) % this.endpoints.length
    return client.client
  }
  
  async search(query, options) {
    // Try each endpoint until success
    for (let i = 0; i < this.endpoints.length; i++) {
      try {
        const client = this.getClient()
        return await client.index('products').search(query, options)
      } catch (error) {
        console.error(`Search failed on endpoint ${i}:`, error)
      }
    }
    throw new Error('All search endpoints failed')
  }
}

Data Partitioning

// Partition by category for better performance
class PartitionedSearch {
  constructor() {
    this.partitions = {
      electronics: new MeiliSearch({ host: process.env.MEILI_ELECTRONICS }),
      clothing: new MeiliSearch({ host: process.env.MEILI_CLOTHING }),
      home: new MeiliSearch({ host: process.env.MEILI_HOME })
    }
  }
  
  async search(query, category = null) {
    if (category && this.partitions[category]) {
      // Search specific partition
      return this.partitions[category].index('products').search(query)
    }
    
    // Search all partitions
    const results = await Promise.all(
      Object.values(this.partitions).map(client => 
        client.index('products').search(query, { limit: 10 })
      )
    )
    
    // Merge and deduplicate results
    return this.mergeResults(results)
  }
}

External Resources

Conclusion

Meilisearch excels in production environments across diverse use cases. Its combination of speed, relevance, and simplicity makes it an excellent choice for e-commerce, documentation, mobile, multi-tenant, and real-time applications.

Key production insights:

  • Configure searchable, filterable, and sortable attributes appropriately
  • Implement proper error handling and retry logic
  • Use caching for frequently accessed content
  • Monitor performance and scale proactively
  • Consider partitioning for very large datasets

These real-world patterns and best practices will help you build robust, scalable search experiences with Meilisearch.

This completes our comprehensive Meilisearch article series. You now have the foundation to implement everything from basic search to sophisticated AI-powered applications.

Comments