Skip to main content

How to Add Search to Your SaaS App (2026)

·StarterPick Team
Share:

TL;DR

Start with PostgreSQL full-text search (free, already in your stack). Upgrade to Typesense or Algolia when you need typo tolerance, facets, or search-as-you-type speed. PostgreSQL FTS handles most SaaS search needs up to millions of rows. This guide covers all three options from simple to powerful.


Why Search Matters for SaaS Retention

Search is a retention feature, not a feature-request checkbox. Users who can find what they're looking for stay. Users who scroll endlessly through lists churn. For any SaaS product with more than a handful of records per user — documents, projects, customers, transactions — search transitions from "nice to have" to "required."

The decision isn't whether to add search, but how much search to add. There is a meaningful difference between "find by name" (basic filter), full-text search (finds matching terms across multiple fields), and faceted search (filter by category, date range, and status simultaneously). Each step up in capability adds development time and infrastructure cost. Start with the simplest option that serves your users, then upgrade based on actual usage patterns.

The search tier system in 2026:

  • Basic filter: Where name ILIKE '%query%'. Zero setup, gets you to first version. Falls apart past 10,000 rows.
  • PostgreSQL FTS: tsvector column + GIN index + tsquery. Free, in your existing database, handles millions of rows with ranking. Sufficient for 90% of SaaS apps.
  • Typesense: Self-hosted or managed, typo-tolerant, sub-50ms search-as-you-type. Needed when users expect Google-level search quality.
  • Algolia: Fully managed, most polished, global CDN-backed. Highest cost ($0.50/1,000 searches on paid plans), zero infrastructure.

No new dependencies — your Postgres database already supports this.

Add a Search Index

// prisma/schema.prisma — add a tsvector column for full-text search
model Document {
  id             String   @id @default(cuid())
  organizationId String
  title          String
  content        String   @db.Text
  searchVector   Unsupported("tsvector")? // Postgres-specific

  @@index([organizationId])
}
-- Migration: add search index
-- prisma/migrations/YYYYMMDD_add_search/migration.sql

-- Create the search vector column
ALTER TABLE "Document" ADD COLUMN "searchVector" tsvector
  GENERATED ALWAYS AS (
    to_tsvector('english', coalesce(title, '') || ' ' || coalesce(content, ''))
  ) STORED;

-- Create index for fast search
CREATE INDEX document_search_idx ON "Document" USING gin("searchVector");

Search Query

// lib/search.ts
import { prisma } from './prisma';

export async function searchDocuments(
  orgId: string,
  query: string,
  limit = 20
) {
  if (!query.trim()) return [];

  // Sanitize query and convert to tsquery format
  const tsQuery = query.trim()
    .split(/\s+/)
    .map(word => word.replace(/[^a-zA-Z0-9]/g, ''))
    .filter(Boolean)
    .join(' & ');

  if (!tsQuery) return [];

  // Raw query needed for full-text search operations
  const results = await prisma.$queryRaw<
    Array<{ id: string; title: string; rank: number; snippet: string }>
  >`
    SELECT
      id,
      title,
      ts_rank("searchVector", to_tsquery('english', ${tsQuery})) AS rank,
      ts_headline(
        'english',
        content,
        to_tsquery('english', ${tsQuery}),
        'StartSel=<mark>, StopSel=</mark>, MaxWords=30, MinWords=15'
      ) AS snippet
    FROM "Document"
    WHERE
      "organizationId" = ${orgId}
      AND "searchVector" @@ to_tsquery('english', ${tsQuery})
    ORDER BY rank DESC
    LIMIT ${limit}
  `;

  return results;
}

Option 2: Typesense (Open Source, Self-Hostable)

For typo tolerance and instant search-as-you-type:

npm install typesense
// lib/typesense.ts
import Typesense from 'typesense';

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

// Create collection schema (run once)
export async function createSearchCollection() {
  await typesense.collections().create({
    name: 'documents',
    fields: [
      { name: 'id', type: 'string' },
      { name: 'organizationId', type: 'string', facet: true },
      { name: 'title', type: 'string' },
      { name: 'content', type: 'string' },
      { name: 'createdAt', type: 'int64' },
    ],
    default_sorting_field: 'createdAt',
  });
}

Indexing Documents

// Index on create/update
export async function indexDocument(doc: {
  id: string;
  organizationId: string;
  title: string;
  content: string;
  createdAt: Date;
}) {
  await typesense.collections('documents').documents().upsert({
    id: doc.id,
    organizationId: doc.organizationId,
    title: doc.title,
    content: doc.content.slice(0, 5000), // Typesense limit
    createdAt: Math.floor(doc.createdAt.getTime() / 1000),
  });
}

// Remove on delete
export async function deleteDocumentFromIndex(docId: string) {
  await typesense.collections('documents').documents(docId).delete();
}

Searching with Typesense

export async function searchDocuments(orgId: string, query: string) {
  const results = await typesense.collections('documents').documents().search({
    q: query,
    query_by: 'title,content',
    filter_by: `organizationId:=${orgId}`,
    highlight_fields: 'title,content',
    num_typos: 2,
    per_page: 20,
  });

  return results.hits?.map(hit => ({
    id: hit.document.id,
    title: hit.document.title,
    snippet: hit.highlights?.[0]?.snippet ?? '',
  })) ?? [];
}

Search UI Component

// components/SearchBox.tsx
'use client';
import { useState, useTransition, useCallback } from 'react';
import { useDebounce } from '@/hooks/useDebounce';
import { SearchIcon } from 'lucide-react';

type SearchResult = { id: string; title: string; snippet: string };

export function SearchBox({ orgId }: { orgId: string }) {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<SearchResult[]>([]);
  const [open, setOpen] = useState(false);
  const [isPending, startTransition] = useTransition();

  const debouncedSearch = useDebounce(async (q: string) => {
    if (!q.trim()) {
      setResults([]);
      return;
    }
    const res = await fetch(`/api/search?q=${encodeURIComponent(q)}&orgId=${orgId}`);
    const data = await res.json();
    setResults(data.results);
  }, 300);

  const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    setQuery(e.target.value);
    setOpen(true);
    startTransition(() => debouncedSearch(e.target.value));
  }, [debouncedSearch]);

  return (
    <div className="relative">
      <div className="relative">
        <SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
        <input
          type="search"
          value={query}
          onChange={handleChange}
          onFocus={() => setOpen(true)}
          onBlur={() => setTimeout(() => setOpen(false), 150)}
          placeholder="Search..."
          className="w-full pl-9 pr-4 py-2 border border-gray-300 rounded-lg text-sm dark:border-gray-600 dark:bg-gray-800"
        />
      </div>

      {open && results.length > 0 && (
        <div className="absolute top-full mt-1 w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-50">
          {results.map(result => (
            <a
              key={result.id}
              href={`/documents/${result.id}`}
              className="block px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700 border-b border-gray-100 dark:border-gray-700 last:border-0"
            >
              <p className="text-sm font-medium text-gray-900 dark:text-white">
                {result.title}
              </p>
              <p
                className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 line-clamp-2"
                dangerouslySetInnerHTML={{ __html: result.snippet }}
              />
            </a>
          ))}
        </div>
      )}
    </div>
  );
}

Keeping the Search Index Fresh

The trickiest part of search is keeping the index synchronized with your database. With PostgreSQL FTS, this is automatic — the GENERATED ALWAYS AS column updates whenever the underlying columns change. With Typesense or Algolia, you need explicit sync logic.

The three patterns for index sync:

Write-through (simplest): Every time you create or update a record, index it inline in the same request handler. Simple but means search indexing is in your critical path — if Typesense is down, your record creation fails too. Acceptable for most products.

Background job: Create/update records normally, publish an event (Redis pub/sub, database trigger, or job queue), and sync search asynchronously. Search index is eventually consistent but record operations are resilient to search provider outages. Use Inngest or BullMQ for the job queue.

Periodic re-index: Run a scheduled job every hour that syncs changed records based on updatedAt timestamp. Works for data that doesn't need real-time search results. Simplest to maintain; handles the case where sync events get dropped.

For most SaaS products, write-through is the right starting point. Switch to background jobs only if you experience search provider outages affecting your write path, or if search indexing is meaningfully slowing down writes.


Search Relevance Tuning

Raw search relevance is often good enough at launch, but product-specific tuning improves results significantly:

Field weighting: Title matches should outrank body content matches. Typesense and PostgreSQL FTS both support weighted field search. query_by: 'title:3,content:1' in Typesense makes title matches 3x more relevant than content.

Boost recent items: For time-sensitive content, add a recency boost. In Typesense, use sort_by: '_text_match:desc,createdAt:desc' to break relevance ties by recency.

Synonyms: Add product-specific synonyms for common alternatives. If your product calls features "workspaces" but users search for "organizations" or "teams," add those as synonyms. Typesense has a built-in synonyms API.

Stop words: Common words like "the", "a", "and" add noise to search results. PostgreSQL's English dictionary handles this automatically. Typesense uses language-specific stop word lists.


Which Option to Choose

RequirementChoose
< 100K rows, basic searchPostgreSQL FTS
Typo tolerance neededTypesense
Search-as-you-type (< 50ms)Typesense or Algolia
Multi-language contentTypesense or Algolia
Zero infra managementAlgolia
Open source + self-hostTypesense

Time Budget

OptionSetup Time
PostgreSQL FTS2-3 hours
Typesense (hosted)Half day
Search UI component2 hours
Index sync logic1-2 hours
Total (PostgreSQL + UI)~1 day

Search UX Patterns That Convert

The search UI is as important as the search algorithm. Even the best relevance ranking produces poor results if the search experience is frustrating. The patterns that have become standard for SaaS applications in 2026:

Command palette (⌘K / Ctrl+K): The keyboard-first search pattern. Press ⌘K anywhere in the app to open a full-screen modal with search and navigation. Users familiar with VS Code, Figma, and Notion expect this. Libraries like cmdk (shadcn/ui uses it) provide the keyboard navigation and filtering primitives. The command palette shows recent searches, suggested actions, and keyboard shortcuts alongside search results.

Search-as-you-type with skeleton loading: Don't wait for the user to press Enter. Search on every keystroke with 200-300ms debounce. Show skeleton placeholders while results load so the interface doesn't feel empty during the search. Users who see instant feedback (even loading states) perceive the search as faster.

No results state with suggestions: When a search returns zero results, don't show a blank page. Show: (1) what was searched for, (2) suggestions to broaden the search, (3) a link to create what they were looking for. "No results for 'invoicce' — did you mean 'invoice'?" is dramatically better than a blank page.

Recent searches: Cache the last 5-10 searches in localStorage. Show them in the search dropdown before the user types. This is particularly valuable for power users who repeat the same searches.


Algolia: The Fully Managed Option

For teams that want zero infrastructure management and the most polished search experience, Algolia is the reference implementation. Algolia's SaaS pricing starts free (10,000 searches/month) and scales to $0.50/1,000 searches.

Algolia's key advantage over Typesense is its global CDN infrastructure. Searches are served from the closest edge node, delivering sub-50ms results worldwide without any infrastructure management. For B2C SaaS products with global users, this geographic distribution matters.

Algolia also provides hosted recommendation models (users who searched X also found Y), synonyms management via dashboard (no code), and A/B testing for ranking algorithms. These are enterprise features; most SaaS products won't need them until significant scale.

The migration path from Typesense to Algolia is straightforward — both use similar document indexing APIs and the instantsearch.js library supports both backends. Design your indexing code to be provider-agnostic and switching is a configuration change.


Scaling Search Beyond PostgreSQL

PostgreSQL FTS scales well to millions of rows with proper indexing, but performance degrades for complex multi-field queries across very large tables. Signs you've outgrown PostgreSQL FTS:

  • Search queries taking more than 200ms consistently
  • GIN index scans consuming significant database CPU
  • Need for real-time results as records are updated at high frequency

When PostgreSQL FTS becomes a bottleneck, the migration to Typesense is the right next step — not Algolia. Typesense is self-hosted, has no per-search pricing, and performs comparably to Algolia for single-region deployments. The migration involves building the Typesense collection schema, implementing write-through indexing on all create/update operations, and switching the search endpoint from the PostgreSQL query to Typesense.

For multi-region SaaS with global users, Typesense Cloud (the hosted version) replicates your search index across regions automatically.


For boilerplates that include search pre-configured, see best boilerplates for internal tools — admin-focused starters often include data table search out of the box. If you're adding AI-powered semantic search (vector similarity search), how to add AI features to any SaaS boilerplate covers pgvector and embeddings-based retrieval. For the multi-tenant context needed to scope search results per organization, team and org management guide covers the organization context pattern.


Search as a Data Source

Search queries are valuable product analytics. What users search for tells you what they expect to find, what terminology they use for your features, and where navigation fails. Users who search for "billing" when you call it "subscriptions" are telling you there's a terminology mismatch.

Log all search queries (sanitized of PII) to a search analytics table: query string, result count, whether the user clicked a result, and which result they clicked. A query with zero results is either a gap in your data or an opportunity for a new feature. A query with many results but no clicks means the results aren't relevant or your result preview isn't useful.

Review the top 20 zero-results queries monthly. Add missing content for queries where users clearly expected to find something. Fix terminology mismatches in your search synonyms. This flywheel — search analytics driving content and UX improvements — compounds over time.


Methodology

Implementation patterns based on PostgreSQL full-text search documentation and Typesense v27 documentation. Performance comparisons derived from community benchmarks in the Next.js and Prisma Discord communities.

Find boilerplates with search built-in on StarterPick.

Check out this boilerplate

View ShipFaston StarterPick →

The SaaS Boilerplate Matrix (Free PDF)

20+ SaaS starters compared: pricing, tech stack, auth, payments, and what you actually ship with. Updated monthly. Used by 150+ founders.

Join 150+ SaaS founders. Unsubscribe in one click.