Skip to main content

How to Add Search to Your SaaS App (2026)

·StarterPick Team
searchtypesensepostgresqlnextjsguide2026

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.


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

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
Total (PostgreSQL + UI)~1 day

Find boilerplates with search built-in on StarterPick.

Check out this boilerplate

View ShipFast on StarterPick →

Comments