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.
Option 1: PostgreSQL Full-Text Search
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
| Requirement | Choose |
|---|---|
| < 100K rows, basic search | PostgreSQL FTS |
| Typo tolerance needed | Typesense |
| Search-as-you-type (< 50ms) | Typesense or Algolia |
| Multi-language content | Typesense or Algolia |
| Zero infra management | Algolia |
| Open source + self-host | Typesense |
Time Budget
| Option | Setup Time |
|---|---|
| PostgreSQL FTS | 2-3 hours |
| Typesense (hosted) | Half day |
| Search UI component | 2 hours |
| Total (PostgreSQL + UI) | ~1 day |
Find boilerplates with search built-in on StarterPick.
Check out this boilerplate
View ShipFast on StarterPick →