Implementing Full-Text Search in Your SaaS: Postgres, Meilisearch, and Typesense Compared
A practical comparison of Postgres full-text search, Meilisearch, and Typesense for SaaS applications. Covers TypeScript integration, indexing strategies, relevance tuning, and a decision framework for when to graduate from Postgres to a dedicated search engine.
Most SaaS products need search. It usually starts as a feature request (“can I search my notes by keyword?”) and quietly becomes load-bearing infrastructure. The mistake most teams make is waiting until search becomes a problem before thinking about the architecture.
The three realistic options for a TypeScript SaaS:
- Postgres full-text search: already in your stack, surprisingly capable, zero extra infrastructure.
- Meilisearch: developer-friendly, typo-tolerant, excellent defaults, self-hostable or managed.
- Typesense: open-source, fast on large datasets, strong filtering capabilities, clear pricing.
This article covers how each option works, realistic TypeScript integration for all three, where each one breaks down, and a decision framework for choosing.
Postgres Full-Text Search
How it works
Postgres has a native full-text search system built on two types: tsvector (a processed document) and tsquery (a search query). The to_tsvector function tokenizes text, strips stop words, and stems terms. The to_tsquery and websearch_to_tsquery functions parse a query string into something Postgres can match against.
// Schema: add a search vector column
// CREATE TABLE documents (
// id UUID PRIMARY KEY,
// title TEXT NOT NULL,
// body TEXT NOT NULL,
// search_vector TSVECTOR GENERATED ALWAYS AS (
// to_tsvector('english', coalesce(title, '') || ' ' || coalesce(body, ''))
// ) STORED
// );
// CREATE INDEX documents_search_idx ON documents USING GIN (search_vector);
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
interface SearchResult {
id: string;
title: string;
rank: number;
headline: string;
}
async function searchDocuments(
query: string,
limit = 20,
offset = 0
): Promise<SearchResult[]> {
const { rows } = await pool.query<SearchResult>(
`
SELECT
id,
title,
ts_rank(search_vector, websearch_to_tsquery('english', $1)) AS rank,
ts_headline(
'english',
body,
websearch_to_tsquery('english', $1),
'MaxWords=30, MinWords=15, StartSel=<mark>, StopSel=</mark>'
) AS headline
FROM documents
WHERE search_vector @@ websearch_to_tsquery('english', $1)
ORDER BY rank DESC
LIMIT $2 OFFSET $3
`,
[query, limit, offset]
);
return rows;
}
websearch_to_tsquery is worth using over the raw to_tsquery function. It accepts natural language syntax (quoted phrases, minus for exclusion) without requiring users to write Postgres query syntax.
Multi-column weighting
Postgres lets you weight different fields so that a match in a title ranks higher than a match in body text:
// In the generated column or at query time:
// to_tsvector('english', title) with weight 'A' > body with weight 'B'
//
// CREATE TABLE documents (
// ...
// search_vector TSVECTOR GENERATED ALWAYS AS (
// setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
// setweight(to_tsvector('english', coalesce(body, '')), 'B')
// ) STORED
// );
// ts_rank_cd respects weights; ts_rank does not by default
const { rows } = await pool.query(
`
SELECT id, title,
ts_rank_cd(search_vector, websearch_to_tsquery('english', $1)) AS rank
FROM documents
WHERE search_vector @@ websearch_to_tsquery('english', $1)
ORDER BY rank DESC
LIMIT 20
`,
[query]
);
Weights map to letters: A (1.0), B (0.4), C (0.2), D (0.1). Adjust with the weights array argument to ts_rank_cd if the defaults do not match your content structure.
Where Postgres breaks down
Postgres full-text search does not handle typos. A user searching for “organisaiton” gets zero results. There is no phonetic matching, no edit-distance tolerance, no “did you mean?” The stemmer helps with minor morphological differences (running matches run), but transpositions and fat-finger typos are dead ends.
Relevance is also limited. Postgres ranks by term frequency and field weight, not by semantic similarity, click signals, or learned preferences. For a simple document index this is fine. For a product search with hundreds of SKUs and varied query intent, it falls short quickly.
Performance degrades on large datasets with complex filters. A GIN index handles the text match efficiently, but combining full-text search with multiple WHERE clauses on unindexed columns requires Postgres to post-filter a potentially large candidate set.
Meilisearch
Architecture
Meilisearch is a standalone search engine written in Rust. You push documents to it via HTTP, and it builds an inverted index with typo tolerance, faceted filtering, and synonym support built in. The defaults are intentionally aggressive: typo tolerance is on, stop words are handled, and relevance tuning has sensible starting weights.
It runs as a single binary with an embedded data store (LMDB). You can self-host on a small VM or use Meilisearch Cloud.
TypeScript integration
import { MeiliSearch } from "meilisearch";
const client = new MeiliSearch({
host: process.env.MEILISEARCH_HOST!,
apiKey: process.env.MEILISEARCH_API_KEY!,
});
interface Document {
id: string;
title: string;
body: string;
authorId: string;
createdAt: number; // Unix timestamp for sorting
tags: string[];
}
// Index configuration (run once at startup or deploy time)
async function configureIndex() {
const index = client.index("documents");
await index.updateSettings({
searchableAttributes: ["title", "body", "tags"],
filterableAttributes: ["authorId", "tags", "createdAt"],
sortableAttributes: ["createdAt"],
typoTolerance: {
enabled: true,
minWordSizeForTypos: {
oneTypo: 4,
twoTypos: 8,
},
},
rankingRules: [
"words",
"typo",
"proximity",
"attribute",
"sort",
"exactness",
],
});
}
// Indexing: push documents on create/update
async function indexDocument(doc: Document) {
const index = client.index("documents");
// addDocuments is upsert: safe to call on updates
await index.addDocuments([doc]);
}
// Search with filtering and pagination
async function searchDocuments(
query: string,
authorId: string,
page = 1,
hitsPerPage = 20
) {
const index = client.index("documents");
return index.search(query, {
filter: `authorId = "${authorId}"`,
attributesToHighlight: ["title", "body"],
highlightPreTag: "<mark>",
highlightPostTag: "</mark>",
page,
hitsPerPage,
});
}
The filterableAttributes setting controls which fields can be used in filter expressions. Fields must be declared filterable before documents are indexed, not after, so this belongs in your index initialization routine.
Keeping the index in sync
The simplest approach for a Postgres-backed SaaS is to index on write:
// In your document creation/update handler
async function createDocument(input: {
title: string;
body: string;
authorId: string;
tags: string[];
}) {
// Write to Postgres first (source of truth)
const { rows } = await pool.query(
`INSERT INTO documents (title, body, author_id, tags, created_at)
VALUES ($1, $2, $3, $4, NOW())
RETURNING id, created_at`,
[input.title, input.body, input.authorId, input.tags]
);
const doc = rows[0];
// Index asynchronously: do not block the response on search index lag
// In production, push this to a background job queue
setImmediate(async () => {
try {
await indexDocument({
id: doc.id,
title: input.title,
body: input.body,
authorId: input.authorId,
createdAt: Math.floor(new Date(doc.created_at).getTime() / 1000),
tags: input.tags,
});
} catch (err) {
// Log and enqueue for retry; do not crash the request handler
console.error("Search index write failed", { docId: doc.id, err });
}
});
return doc;
}
The index write is eventually consistent. If Meilisearch is temporarily unavailable, writes fail silently. For a SaaS where search is a convenience feature (not the primary data store), this is an acceptable tradeoff. For cases where search needs to be consistent with writes, push the indexing task to a durable queue and retry on failure.
Where Meilisearch breaks down
Meilisearch is single-node by design. High availability requires running a replica (available in v1.x with Meilisearch Cloud, more complex to DIY). For most SaaS products this is fine; for high-availability requirements it is a constraint worth knowing upfront.
The ranking model is static: you configure rules, but there is no built-in support for learning from click signals or behavioral data. Relevance tuning requires manual iteration on ranking rules and attribute weights.
Typesense
Architecture
Typesense is also written in Rust and operates on similar principles to Meilisearch. The differentiators: it supports multi-node clustering natively (strong consistency via Raft), its filtering and faceting performance is particularly fast on large datasets, and its pricing model (self-hosted is free, Typesense Cloud charges by node-hour) suits teams that want to avoid per-query costs.
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,
});
// Schema definition (create once)
const schema = {
name: "documents",
fields: [
{ name: "id", type: "string" as const },
{ name: "title", type: "string" as const },
{ name: "body", type: "string" as const },
{ name: "authorId", type: "string" as const, facet: true },
{ name: "tags", type: "string[]" as const, facet: true },
{ name: "createdAt", type: "int64" as const },
],
default_sorting_field: "createdAt",
};
async function ensureCollection() {
try {
await client.collections("documents").retrieve();
} catch {
await client.collections().create(schema);
}
}
// Upsert a document
async function indexDocument(doc: {
id: string;
title: string;
body: string;
authorId: string;
tags: string[];
createdAt: number;
}) {
await client
.collections("documents")
.documents()
.upsert(doc);
}
// Search with filter and faceting
async function searchDocuments(
query: string,
authorId: string,
page = 1
) {
return client.collections("documents").documents().search({
q: query,
query_by: "title,body,tags",
query_by_weights: "3,1,2",
filter_by: `authorId:=${authorId}`,
sort_by: "_text_match:desc,createdAt:desc",
highlight_full_fields: "title",
page,
per_page: 20,
});
}
query_by_weights is the Typesense equivalent of Meilisearch’s searchableAttributes ordering. A title match weighted at 3 ranks above a tags match at 2 and a body match at 1.
Multi-tenancy with scoped API keys
For SaaS, each tenant should only see their own data. Typesense handles this with scoped API keys, which embed a filter at the key level:
async function generateScopedSearchKey(authorId: string): Promise<string> {
const keyWithSearchPermissions = await client.keys().create({
description: `Search key for user ${authorId}`,
actions: ["documents:search"],
collections: ["documents"],
// Embedded filter: every search using this key will AND with this filter
value_regex: `authorId:=${authorId}`,
});
// In practice, use Typesense's generateScopedSearchKey helper
// which embeds parameters without creating a persistent key
const { TypesenseInstantsearchAdapter } = await import(
"typesense-instantsearch-adapter"
);
return client.keys().generateScopedSearchKey(
keyWithSearchPermissions.value!,
{ filter_by: `authorId:=${authorId}`, expires_at: Date.now() + 3600 }
);
}
The scoped key approach means your frontend can call Typesense directly without going through your API server for every search request. This removes a round-trip and simplifies your server-side search code.
Where Typesense breaks down
Typesense’s cluster setup has more operational overhead than Meilisearch’s single-node model. If you are running it yourself, you need three nodes for the Raft quorum. The Typesense Cloud managed option sidesteps this, but the pricing model (per-node-hour rather than per-request) means costs scale with uptime, not with usage.
The documentation is good but thinner than Meilisearch’s. Community resources are smaller. If you hit an edge case, you are more likely to be debugging from source or filing an issue.
Tradeoffs Table
| Dimension | Postgres FTS | Meilisearch | Typesense |
|---|---|---|---|
| Typo tolerance | None | Built-in, configurable | Built-in, configurable |
| Extra infrastructure | None | 1 node minimum | 1 node (3 for HA) |
| Relevance tuning | Field weights, ts_rank | Ranking rules, synonyms | Field weights, overrides |
| Filtering performance | Good (with indexes) | Good | Excellent on large sets |
| Multi-tenancy | Row-level security | Filter per query | Scoped API keys |
| HA / clustering | Postgres replication | Single-node (Meilisearch Cloud for HA) | Native Raft clustering |
| Self-hosted cost | Zero | Zero | Zero |
| Managed cost | Included in DB | Meilisearch Cloud pricing | Node-hour billing |
| Index sync complexity | Lowest (same DB) | Medium (HTTP push) | Medium (HTTP push) |
| Fuzzy / semantic | No | No (lexical only) | No (lexical only) |
Production Considerations
Index sync failures
When Postgres is your source of truth and a dedicated search engine is your read layer, index writes can fail silently. Two patterns reduce data loss:
- Push index writes to a durable job queue (BullMQ, Inngest) and retry on failure. The queue provides durability; the search engine gets eventually consistent writes.
- Periodically run a reconciliation job that diffs Postgres against the search index on a rolling basis (by
updatedAtwindow). This catches failures that exhausted retry budgets.
Do not block the user-facing write on search index success. The search index is a derived view, not the primary record.
Multi-tenant data isolation
Postgres full-text search inherits your existing row-level security setup. No extra work.
Meilisearch and Typesense both rely on filter conditions to isolate tenant data. The risk: if you forget to include the tenant filter in a search query, you leak cross-tenant results. Centralize search calls behind a single function that always applies the tenant filter, rather than letting callers construct raw queries.
// Wrap Meilisearch search to enforce tenant isolation
async function tenantSearch(
tenantId: string,
query: string,
options: Partial<Parameters<typeof index.search>[1]> = {}
) {
const index = client.index("documents");
const tenantFilter = `tenantId = "${tenantId}"`;
const existingFilter = options.filter;
return index.search(query, {
...options,
filter: existingFilter
? `${tenantFilter} AND (${existingFilter})`
: tenantFilter,
});
}
Observability
The minimum search observability you want in production:
- Query latency p50/p95/p99: Meilisearch and Typesense expose metrics endpoints; scrape with Prometheus or ship to your APM tool.
- Zero-result rate by query: a high zero-result rate often indicates a synonym gap or a relevance problem, not missing data.
- Index lag: time between a write to Postgres and availability in the search index. Alert when this exceeds your SLA.
- Index size drift: the document count in your search engine should match your Postgres table count. A growing gap indicates a sync failure.
Re-indexing without downtime
When you change schema (add a filterable field, change weighting), you need to re-index. The zero-downtime approach for both Meilisearch and Typesense:
- Create a new index with the updated schema (e.g.,
documents_v2). - Run a backfill job that reads from Postgres and writes to the new index.
- Once backfill completes, switch your search queries to the new index atomically.
- Delete the old index.
Typesense also supports collection aliases, which make the swap atomic at the API level: documents alias points to documents_v1, you update the alias to documents_v2, all search queries transparently use the new collection.
Decision Framework
Start here: Are you already on Postgres and does your data volume fit in a single-digit million rows?
Use Postgres full-text search if:
- Your corpus is under 5 million rows and user queries are reasonably precise.
- You want zero extra infrastructure to operate.
- Typo tolerance is not a product requirement (internal tools, structured data search).
- You are pre-product-market fit and want to defer infrastructure decisions.
Move to Meilisearch if:
- Users are searching with natural language and making typos.
- You want excellent defaults with minimal configuration time.
- Your team prefers a simpler operational model (single node, easy self-hosting).
- You are on a managed host that makes running a single extra service cheap.
Move to Typesense if:
- You need native HA clustering without a managed service.
- You have a large dataset with complex faceted filtering requirements.
- You want to avoid per-query costs and the node-hour model fits your usage pattern.
- You need scoped API keys for frontend-direct search in a multi-tenant product.
Neither Meilisearch nor Typesense provides semantic search. If your product needs results based on meaning rather than lexical overlap (finding “invoice” when the user types “bill”), you need a vector database or a hybrid approach layering dense embeddings on top of one of these engines. That is a different architecture with different operational costs.
The Graduation Path
Most SaaS products should start with Postgres full-text search. The operational cost is zero, the integration is a few SQL functions, and it handles moderate search volumes well enough that users do not complain. The moment you start getting bug reports about typos returning no results, or when your search queries are causing noticeable Postgres load, that is the signal to evaluate a dedicated engine.
The migration is not painful if you planned for it. Keep your search calls behind a single interface from the start. When you switch from Postgres to Meilisearch or Typesense, you swap one implementation behind that interface, run a backfill, and cut over. The applications layer does not change.
The wrong time to think about this architecture is after search performance becomes a production incident.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.