Back to Blog

Search Implementation

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

Viprasol Tech Team
13 min read
Updated 2026

Search Implementation: Elasticsearch vs Typesense vs Meilisearch for SaaS Products

Quick answer. For most products under 10 million documents, PostgreSQL full-text search with a tsvector column is enough, so start there before adding infrastructure. When you outgrow it, choose Typesense or Meilisearch for fast typo-tolerant search that's simple to run, and Elasticsearch when you need advanced querying and analytics at scale.

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 1000+ 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`);
  }
}

search - Search Implementation

🚀 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>
  );
}

Our Capabilities

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.


Additional Resources

searchelasticsearchmeilisearchtypesensepostgresql
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 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.

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.