Back to blog
searchfull-text-searchpostgresqlelasticsearchtypesensesaas

Search Functionality in Your SaaS: From Simple Filters to Full-Text Search

By SaaS Masters19 maart 202612 min read

Search Functionality in Your SaaS: From Simple Filters to Full-Text Search

Users expect to find everything in your application instantly. Whether it's products, documents, customers, or messages — a great search experience is the difference between a tool people use daily and one they forget about after a week. In this article, we'll dive deep into the different options for search functionality in your SaaS, from database-level queries to dedicated search engines.

Why Search Matters So Much

Research consistently shows that users who use search functionality convert 2-3x more than users who only browse. In a SaaS context, this means:

  • Higher retention: users find what they need faster
  • Lower support load: fewer "where do I find X?" tickets
  • Better data discovery: users discover features they would otherwise miss

Yet many SaaS founders treat search as an afterthought. Let's change that.

Level 1: SQL LIKE and ILIKE

The simplest form of search happens directly in your database:

SELECT * FROM products 
WHERE name ILIKE '%searchterm%' 
   OR description ILIKE '%searchterm%';

When This Works Fine

  • Small datasets (< 10,000 records)
  • Simple exact matches
  • Admin panels where speed is less critical

Limitations

  • No relevance ranking: all results are "equal"
  • No fuzzy matching: a typo = zero results
  • Performance: LIKE '%term%' can't use an index and gets slower as your table grows
  • No synonyms or stemming: "bicycles" won't match "bicycle"

Level 2: PostgreSQL Full-Text Search

If you're already using PostgreSQL (and most modern SaaS applications do), you have a surprisingly powerful search engine built in:

-- Add a tsvector column
ALTER TABLE products ADD COLUMN search_vector tsvector;

-- Populate the vector
UPDATE products SET search_vector = 
  setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
  setweight(to_tsvector('english', coalesce(description, '')), 'B');

-- Create a GIN index
CREATE INDEX idx_products_search ON products USING GIN(search_vector);

-- Search with ranking
SELECT name, ts_rank(search_vector, query) AS rank
FROM products, plainto_tsquery('english', 'cloud hosting') AS query
WHERE search_vector @@ query
ORDER BY rank DESC;

Advantages

  • No extra infrastructure: it's already in your database
  • Relevance ranking: weighted scores based on where matches occur
  • Language support: stemming for English, Dutch, and 20+ languages
  • Trigram extension: fuzzy matching via pg_trgm

Trigram Matching for Typo Tolerance

CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_products_name_trgm ON products USING GIN(name gin_trgm_ops);

-- Search with typo tolerance
SELECT name, similarity(name, 'clou hostnig') AS sim
FROM products
WHERE name % 'clou hostnig'
ORDER BY sim DESC;

When PostgreSQL FTS Is Enough

  • Up to ~1 million records
  • When you don't want to manage a separate system
  • Standard search functionality without advanced features
  • You want transactional consistency (search index is always up-to-date)

Level 3: Dedicated Search Engines

When your search requirements grow, it's time for a dedicated solution. The three major players:

Elasticsearch / OpenSearch

The industry standard for full-text search:

// Index a document
await client.index({
  index: 'products',
  body: {
    name: 'Cloud Hosting Pro',
    description: 'Scalable hosting for SaaS applications',
    tags: ['hosting', 'cloud', 'saas'],
    price: 49.99,
    createdAt: new Date()
  }
});

// Search with boosting and fuzzy matching
const result = await client.search({
  index: 'products',
  body: {
    query: {
      bool: {
        should: [
          { match: { name: { query: 'cloud hosting', boost: 2, fuzziness: 'AUTO' } } },
          { match: { description: { query: 'cloud hosting', fuzziness: 'AUTO' } } },
          { match: { tags: { query: 'cloud hosting', boost: 1.5 } } }
        ]
      }
    },
    highlight: {
      fields: { name: {}, description: {} }
    }
  }
});

Pros: extremely powerful, scales to billions of documents, rich query DSL, aggregations. Cons: resource-intensive (RAM-hungry), complex ops, requires sync with primary database.

Typesense

A more modern, developer-friendly alternative:

// Define schema
await typesense.collections().create({
  name: 'products',
  fields: [
    { name: 'name', type: 'string' },
    { name: 'description', type: 'string' },
    { name: 'price', type: 'float', facet: true },
    { name: 'tags', type: 'string[]', facet: true }
  ],
  default_sorting_field: 'price'
});

// Search with typo tolerance (on by default!)
const results = await typesense
  .collections('products')
  .documents()
  .search({
    q: 'clod hostnig',  // typos are automatically corrected
    query_by: 'name,description,tags',
    query_by_weights: '3,1,2',
    filter_by: 'price:<100',
    facet_by: 'tags',
    sort_by: '_text_match:desc,price:asc'
  });

Pros: quick setup, typo tolerance out-of-the-box, low memory usage, built-in geo-search. Cons: less advanced query capabilities than Elasticsearch, smaller community.

Meilisearch

Similar to Typesense, with a focus on speed:

await meili.index('products').addDocuments(products);

const results = await meili.index('products').search('cloud hosting', {
  attributesToHighlight: ['name', 'description'],
  filter: 'price < 100',
  facets: ['tags'],
  limit: 20
});

Pros: blazing-fast responses (< 50ms), simple API, multi-tenancy support. Cons: less suitable for analytics/aggregations, more limited filtering.

The Synchronization Challenge

The biggest problem with an external search engine is data synchronization. Your primary database is the source of truth, but your search index needs to stay current.

Strategy 1: Synchronous Updates

async function createProduct(data) {
  const product = await prisma.product.create({ data });
  
  // Index immediately
  await searchClient.index('products').addDocuments([product]);
  
  return product;
}

Problem: if search indexing fails, you have inconsistent data. And it slows down your write path.

Strategy 2: Event-Driven Sync (Recommended)

// On every database mutation: push event to queue
async function createProduct(data) {
  const product = await prisma.product.create({ data });
  
  await queue.publish('product.created', { productId: product.id });
  
  return product;
}

// Worker listens for events
queue.subscribe('product.*', async (event) => {
  const product = await prisma.product.findUnique({ 
    where: { id: event.productId } 
  });
  
  if (event.type === 'product.deleted') {
    await searchClient.index('products').deleteDocument(event.productId);
  } else {
    await searchClient.index('products').addDocuments([product]);
  }
});

Strategy 3: Periodic Re-index

For less critical use cases, you can periodically rebuild your entire index:

// Daily cron job
async function reindexProducts() {
  const products = await prisma.product.findMany();
  await searchClient.index('products').addDocuments(products, { primaryKey: 'id' });
}

Search UX: The Details That Make the Difference

A great search engine is only half the story. The UX makes or breaks the experience:

1. Instant Search (Search-as-You-Type)

// React component with debounce
function SearchBar() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 200);
  
  const { data: results } = useSWR(
    debouncedQuery ? `/api/search?q=${debouncedQuery}` : null,
    fetcher
  );

  return (
    <div className="relative">
      <input
        type="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search products..."
        className="w-full px-4 py-2 border rounded-lg"
      />
      {results && (
        <div className="absolute top-full mt-1 w-full bg-white shadow-lg rounded-lg">
          {results.hits.map(hit => (
            <SearchResult key={hit.id} hit={hit} />
          ))}
        </div>
      )}
    </div>
  );
}

2. Highlighting Matches

Show users why a result matches:

function SearchResult({ hit }) {
  return (
    <div className="p-3 hover:bg-gray-50">
      <h3 dangerouslySetInnerHTML={{ 
        __html: hit._highlightResult?.name?.value || hit.name 
      }} />
      <p className="text-sm text-gray-600" dangerouslySetInnerHTML={{ 
        __html: hit._highlightResult?.description?.value || hit.description 
      }} />
    </div>
  );
}

3. Faceted Filtering

Combine search with filters for a powerful discovery experience:

  • Category filters
  • Price ranges
  • Tags and labels
  • Date filters

4. Recent and Popular Searches

// Store searches for suggestions
async function trackSearch(userId: string, query: string) {
  await prisma.searchLog.create({
    data: { userId, query, timestamp: new Date() }
  });
}

// Show popular searches
async function getPopularSearches() {
  return prisma.searchLog.groupBy({
    by: ['query'],
    _count: { query: true },
    orderBy: { _count: { query: 'desc' } },
    take: 10,
    where: {
      timestamp: { gte: subDays(new Date(), 7) }
    }
  });
}

Multi-Tenant Search

In a multi-tenant SaaS, you must isolate search results per tenant:

// Typesense: use scoped API keys
const scopedKey = typesense.keys().generateScopedSearchKey(
  searchOnlyApiKey,
  { filter_by: `tenant_id:=${tenantId}` }
);

// Elasticsearch: filter on tenant
const results = await client.search({
  index: 'documents',
  body: {
    query: {
      bool: {
        must: [
          { match: { content: userQuery } }
        ],
        filter: [
          { term: { tenant_id: tenantId } }
        ]
      }
    }
  }
});

Important: always verify that a user truly cannot see data from other tenants. This is a security-critical feature.

Which Solution Should You Choose?

SituationRecommendation
MVP / < 10K recordsSQL LIKE/ILIKE
< 1M records, PostgreSQLPostgreSQL FTS + pg_trgm
> 1M records, complex queriesElasticsearch/OpenSearch
Developer-friendly, quick setupTypesense or Meilisearch
Want to avoid self-hostingAlgolia (managed, but expensive)

Practical Checklist

Before building search functionality, run through this checklist:

  • Define your search use cases: what exactly are users searching for?
  • Choose your stack: PostgreSQL FTS is often enough for v1
  • Build a sync strategy: how does your index stay up-to-date?
  • Implement debouncing: prevent unnecessary API calls
  • Add highlighting: show why something matches
  • Test with real data: synthetic data hides performance problems
  • Monitor search performance: track p95 latency and zero-result queries
  • Isolate tenant data: verify multi-tenant security

Conclusion

Search functionality is one of those features that seems simple but runs deep. Start with PostgreSQL full-text search — it's surprisingly powerful and you don't need to manage extra infrastructure. Outgrowing it? Typesense or Meilisearch is an excellent next step you can set up in an afternoon.

Most importantly: measure what your users search for. Zero-result queries are worth their weight in gold — they tell you exactly what's missing in your product or your search configuration. Build analytics in from day one, and your search functionality becomes not just a feature, but a strategic weapon.