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
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
| Engine | Self-Host | Cloud | Typo Tolerance | Facets | Best For |
|---|---|---|---|---|---|
| Elasticsearch | ✅ Complex | AWS OpenSearch, Elastic Cloud ($95+/mo) | Good | Excellent | Log analytics, complex queries, large scale |
| Typesense | ✅ Simple | $25–200/mo | Excellent (built-in) | Good | Application search, fast setup, modern API |
| Meilisearch | ✅ Simple | $35–300/mo | Excellent (built-in) | Good | Developer experience, instant search |
| Algolia | ❌ | $0–1,500+/mo | Excellent | Excellent | Speed, global CDN, generous free tier |
| OpenSearch | ✅ | AWS managed | Good | Good | AWS-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
- PostgreSQL Performance Optimization — PostgreSQL FTS optimization
- Redis Use Cases — caching search results
- API Gateway Patterns — rate limiting search endpoints
- Vector Database Guide — semantic search alongside keyword search
- Web Development Services — search and product development
About the Author
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.
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
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.