Back to Blog

Search Implementation: Elasticsearch vs Typesense vs Meilisearch for SaaS Products

Implement search for SaaS — Elasticsearch vs Typesense vs Meilisearch comparison, full-text search with PostgreSQL, indexing strategies, faceted search, typo to

Viprasol Tech Team
May 17, 2026
13 min read

Search Implementation: Elasticsearch vs Typesense vs Meilisearch for SaaS Products

Search is one of the features that most dramatically affects how useful your product feels. A fast, typo-tolerant search that finds the right result in 50ms feels like magic. A slow search that misses obvious results sends users back to filtering tables manually.

The first decision is whether to use your existing database or a dedicated search engine. The second is which search engine.


PostgreSQL Full-Text Search: Start Here

Before reaching for Elasticsearch, check if PostgreSQL full-text search meets your needs. For most products under 10 million documents, it does.

-- Add full-text search to an existing table
ALTER TABLE projects ADD COLUMN search_vector TSVECTOR;

-- Populate search vector from multiple columns
UPDATE projects SET search_vector =
  setweight(to_tsvector('english', COALESCE(name, '')), 'A') ||        -- title: highest weight
  setweight(to_tsvector('english', COALESCE(description, '')), 'B') || -- desc: medium weight
  setweight(to_tsvector('english', COALESCE(tags::text, '')), 'C');    -- tags: lower weight

-- Keep search vector updated automatically
CREATE OR REPLACE FUNCTION update_project_search_vector()
RETURNS TRIGGER AS $$
BEGIN
  NEW.search_vector :=
    setweight(to_tsvector('english', COALESCE(NEW.name, '')), 'A') ||
    setweight(to_tsvector('english', COALESCE(NEW.description, '')), 'B') ||
    setweight(to_tsvector('english', COALESCE(NEW.tags::text, '')), 'C');
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER projects_search_vector_update
  BEFORE INSERT OR UPDATE ON projects
  FOR EACH ROW EXECUTE FUNCTION update_project_search_vector();

-- GIN index for fast search
CREATE INDEX idx_projects_search ON projects USING GIN(search_vector);

-- Search query with ranking
SELECT
  id, name, description,
  ts_rank(search_vector, query) AS rank
FROM projects,
  to_tsquery('english', 'payment & integration') AS query
WHERE search_vector @@ query
  AND tenant_id = $1
ORDER BY rank DESC
LIMIT 20;

PostgreSQL full-text limitations:

  • No typo tolerance ("paymnt" won't find "payment")
  • Limited relevance tuning
  • No built-in faceted search
  • Slower than dedicated search engines above ~1M documents

For anything beyond basic keyword matching: use a dedicated search engine.


Search Engine Comparison

EngineSelf-HostCloudTypo ToleranceFacetsBest For
Elasticsearch✅ ComplexAWS OpenSearch, Elastic Cloud ($95+/mo)GoodExcellentLog analytics, complex queries, large scale
Typesense✅ Simple$25–200/moExcellent (built-in)GoodApplication search, fast setup, modern API
Meilisearch✅ Simple$35–300/moExcellent (built-in)GoodDeveloper experience, instant search
Algolia$0–1,500+/moExcellentExcellentSpeed, global CDN, generous free tier
OpenSearchAWS managedGoodGoodAWS-native, Elasticsearch compatible

Recommendation matrix:

  • Startup / under 100K documents: Typesense or Meilisearch (simple to operate, instant typo tolerance)
  • Medium SaaS / complex queries: Elasticsearch or OpenSearch
  • E-commerce / product search: Algolia (global CDN for consistent <50ms globally)
  • Log search / analytics: Elasticsearch (built for it)

🌐 Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different — senior engineers only, direct Slack access, and a 5.0★ Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript — production-grade stack
  • Fixed-price contracts — no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Typesense Implementation

// lib/search/typesense.ts
import Typesense from 'typesense';

const client = new Typesense.Client({
  nodes: [{ host: process.env.TYPESENSE_HOST!, port: 443, protocol: 'https' }],
  apiKey: process.env.TYPESENSE_API_KEY!,
  connectionTimeoutSeconds: 2,
});

// Define collection schema
const projectsSchema = {
  name: 'projects',
  fields: [
    { name: 'id', type: 'string' as const },
    { name: 'tenantId', type: 'string' as const, facet: true },
    { name: 'name', type: 'string' as const },
    { name: 'description', type: 'string' as const, optional: true },
    { name: 'status', type: 'string' as const, facet: true },
    { name: 'tags', type: 'string[]' as const, facet: true },
    { name: 'memberCount', type: 'int32' as const },
    { name: 'createdAt', type: 'int64' as const },  // Unix timestamp for sorting
  ],
  default_sorting_field: 'createdAt',
};

export async function createProjectsIndex() {
  try {
    await client.collections('projects').retrieve();
  } catch {
    // Collection doesn't exist — create it
    await client.collections().create(projectsSchema);
  }
}

// Index a project document
export async function indexProject(project: {
  id: string;
  tenantId: string;
  name: string;
  description?: string;
  status: string;
  tags: string[];
  memberCount: number;
  createdAt: Date;
}) {
  await client.collections('projects').documents().upsert({
    ...project,
    createdAt: Math.floor(project.createdAt.getTime() / 1000),
  });
}

// Search with tenant isolation
export async function searchProjects(params: {
  tenantId: string;
  query: string;
  status?: string;
  tags?: string[];
  page?: number;
  perPage?: number;
}) {
  const filterParts = [`tenantId:=${params.tenantId}`];
  if (params.status) filterParts.push(`status:=${params.status}`);
  if (params.tags?.length) filterParts.push(`tags:=[${params.tags.join(',')}]`);

  const results = await client.collections('projects').documents().search({
    q: params.query || '*',
    query_by: 'name,description,tags',
    query_by_weights: '3,2,1',     // Weight name matches highest
    filter_by: filterParts.join(' && '),
    sort_by: params.query ? '_text_match:desc' : 'createdAt:desc',
    facet_by: 'status,tags',       // Return facet counts for filtering UI
    max_facet_values: 20,
    per_page: params.perPage ?? 20,
    page: params.page ?? 1,
    typo_tokens_threshold: 1,      // Allow 1 typo per token
    num_typos: 2,
    highlight_full_fields: 'name,description',
  });

  return {
    hits: results.hits?.map(hit => ({
      ...hit.document,
      highlights: hit.highlights,
    })) ?? [],
    found: results.found,
    facets: results.facet_counts,
    page: results.page,
    totalPages: Math.ceil(results.found / (params.perPage ?? 20)),
  };
}

Keeping Search Index in Sync

// lib/search/sync.ts
// Sync strategy: update search index after DB writes

// Option 1: Direct sync (simple, synchronous)
export async function createProject(data: CreateProjectInput) {
  const project = await db.projects.create(data);

  // Sync to search index (fire-and-forget — don't fail the API on search errors)
  indexProject(project).catch(err => {
    logger.error('Failed to index project in search', { projectId: project.id, err });
  });

  return project;
}

// Option 2: Queue-based sync (more reliable, handles backpressure)
export async function createProject(data: CreateProjectInput) {
  const project = await db.projects.create(data);

  await searchSyncQueue.add('index-project', {
    operation: 'upsert',
    collection: 'projects',
    documentId: project.id,
  });

  return project;
}

// Worker that fetches from DB and indexes
searchSyncWorker.process(async (job) => {
  const { operation, collection, documentId } = job.data;

  if (operation === 'upsert') {
    const project = await db.projects.findById(documentId);
    if (project) await indexProject(project);
  } else if (operation === 'delete') {
    await client.collections(collection).documents(documentId).delete();
  }
});

// Option 3: Full re-index (for schema changes or recovery)
export async function reindexAllProjects() {
  await client.collections('projects').delete();
  await createProjectsIndex();

  let offset = 0;
  const batchSize = 1000;

  while (true) {
    const projects = await db.projects.findMany({ take: batchSize, skip: offset });
    if (projects.length === 0) break;

    // Bulk import — much faster than individual upserts
    await client.collections('projects').documents().import(
      projects.map(p => ({ ...p, createdAt: Math.floor(p.createdAt.getTime() / 1000) })),
      { action: 'upsert' }
    );

    offset += batchSize;
    console.log(`Indexed ${offset} projects`);
  }
}

🚀 Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4–8 weeks, full platforms in 3–5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Search UI: Instant Search

// components/ProjectSearch.tsx
import { useState, useCallback, useEffect } from 'react';
import { useDebounce } from 'use-debounce';

interface SearchResult {
  id: string;
  name: string;
  description?: string;
  status: string;
  highlights?: Array<{ field: string; snippet: string }>;
}

export function ProjectSearch() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<SearchResult[]>([]);
  const [facets, setFacets] = useState<Record<string, number>>({});
  const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(false);

  const [debouncedQuery] = useDebounce(query, 150);  // 150ms debounce

  useEffect(() => {
    const search = async () => {
      if (!debouncedQuery && !selectedStatus) {
        setResults([]);
        return;
      }

      setIsLoading(true);
      try {
        const response = await fetch('/api/search/projects?' + new URLSearchParams({
          q: debouncedQuery,
          ...(selectedStatus && { status: selectedStatus }),
        }));
        const data = await response.json();
        setResults(data.hits);
        setFacets(
          data.facets?.find((f: any) => f.field_name === 'status')?.counts
            .reduce((acc: any, c: any) => ({ ...acc, [c.value]: c.count }), {}) ?? {}
        );
      } finally {
        setIsLoading(false);
      }
    };

    search();
  }, [debouncedQuery, selectedStatus]);

  return (
    <div className="relative">
      <input
        type="search"
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder="Search projects..."
        className="w-full px-4 py-2 border rounded-lg"
      />

      {/* Status facet filters */}
      <div className="flex gap-2 mt-2">
        {Object.entries(facets).map(([status, count]) => (
          <button
            key={status}
            onClick={() => setSelectedStatus(selectedStatus === status ? null : status)}
            className={`px-3 py-1 text-sm rounded-full border ${
              selectedStatus === status ? 'bg-blue-100 border-blue-300' : 'bg-white'
            }`}
          >
            {status} ({count})
          </button>
        ))}
      </div>

      {/* Results */}
      {results.length > 0 && (
        <div className="mt-2 border rounded-lg divide-y">
          {results.map(result => (
            <a key={result.id} href={`/projects/${result.id}`} className="block p-3 hover:bg-gray-50">
              <div className="font-medium"
                dangerouslySetInnerHTML={{
                  __html: result.highlights?.find(h => h.field === 'name')?.snippet ?? result.name
                }}
              />
              {result.description && (
                <p className="text-sm text-gray-500 truncate">{result.description}</p>
              )}
            </a>
          ))}
        </div>
      )}
    </div>
  );
}

Working With Viprasol

We implement search infrastructure for SaaS products — from PostgreSQL full-text for early-stage to Typesense/Elasticsearch for production scale. Our work includes index design, relevance tuning, faceted search, and multi-tenant data isolation in the search layer.

Talk to our team about search implementation for your product.


See Also

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms — we build it all with Next.js and React.

Free consultation • No commitment • Response within 24 hours

Viprasol · Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse ≥90 scores, mobile-first design, and full source code ownership. Senior engineers only — from architecture through deployment.