SaaS Search Autocomplete in 2026: Debounce, Keyboard Navigation, and Search Analytics
Build production search autocomplete for SaaS: debounced queries, keyboard navigation, recent searches, PostgreSQL full-text, Elasticsearch suggestions, and search analytics.
SaaS Search Autocomplete in 2026: Debounce, Keyboard Navigation, and Search Analytics
A well-built search box is one of the highest-leverage UX investments in a SaaS product. Users who search convert better, retain longer, and find value faster. A poorly-built oneβslow queries, no keyboard nav, no memory of recent searchesβsends users to support instead.
This post builds the complete autocomplete system: debounced queries that don't hammer your database, a PostgreSQL full-text backend with weighted ranking, a React component with full keyboard navigation and accessibility, recent search history, and search analytics to understand what users are actually looking for.
Backend: PostgreSQL Full-Text Autocomplete
For most SaaS products under 1M records, PostgreSQL's built-in full-text search is sufficientβno Elasticsearch required.
-- migrations/20260101_search_autocomplete.sql
-- Add a search vector column to your searchable tables
ALTER TABLE projects ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
setweight(to_tsvector('english', coalesce(description, '')), 'B') ||
setweight(to_tsvector('english', coalesce(tags::text, '')), 'C')
) STORED;
CREATE INDEX idx_projects_search ON projects USING gin(search_vector);
-- Search suggestions table (cached popular terms)
CREATE TABLE search_suggestions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
query TEXT NOT NULL,
result_type TEXT NOT NULL, -- 'project', 'task', 'member', etc.
result_id TEXT NOT NULL,
result_title TEXT NOT NULL,
score FLOAT NOT NULL DEFAULT 1.0,
hit_count INTEGER NOT NULL DEFAULT 1,
last_hit_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (team_id, result_type, result_id)
);
CREATE INDEX idx_suggestions_team_query ON search_suggestions(team_id, query text_pattern_ops);
CREATE INDEX idx_suggestions_score ON search_suggestions(team_id, score DESC, hit_count DESC);
-- Search analytics
CREATE TABLE search_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID NOT NULL,
user_id UUID REFERENCES users(id),
query TEXT NOT NULL,
result_count INTEGER NOT NULL DEFAULT 0,
selected_result_id TEXT, -- NULL if user didn't click anything
selected_result_type TEXT,
session_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_search_events_team ON search_events(team_id, created_at DESC);
CREATE INDEX idx_search_events_query ON search_events(team_id, query, created_at DESC);
Search API
// app/api/search/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { db } from "@/lib/db";
import { z } from "zod";
const SearchSchema = z.object({
q: z.string().min(1).max(200),
types: z.string().optional(), // "project,task,member"
limit: z.coerce.number().min(1).max(20).default(8),
});
export async function GET(req: NextRequest) {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const params = Object.fromEntries(req.nextUrl.searchParams);
const parsed = SearchSchema.safeParse(params);
if (!parsed.success) return NextResponse.json({ error: "Bad request" }, { status: 400 });
const { q, limit } = parsed.data;
const types = parsed.data.types?.split(",") ?? ["project", "task", "member"];
// Parallel search across requested types
const [projects, tasks, members] = await Promise.all([
types.includes("project") ? searchProjects(user.teamId, q, Math.ceil(limit / 2)) : [],
types.includes("task") ? searchTasks(user.teamId, q, Math.ceil(limit / 3)) : [],
types.includes("member") ? searchMembers(user.teamId, q, 3) : [],
]);
// Merge and rank results
const results = [
...projects.map((r) => ({ ...r, type: "project" })),
...tasks.map((r) => ({ ...r, type: "task" })),
...members.map((r) => ({ ...r, type: "member" })),
]
.sort((a, b) => b.rank - a.rank)
.slice(0, limit);
// Record search event (non-blocking)
recordSearchEvent({
teamId: user.teamId,
userId: user.id,
query: q,
resultCount: results.length,
}).catch(console.error);
return NextResponse.json({ results, query: q });
}
async function searchProjects(teamId: string, query: string, limit: number) {
// Use websearch_to_tsquery for user-friendly query parsing
// (handles quoted phrases, OR, AND without requiring tsquery syntax)
return db.$queryRaw<Array<{ id: string; title: string; description: string; rank: number }>>`
SELECT
id,
name AS title,
description,
ts_rank_cd(search_vector, websearch_to_tsquery('english', ${query})) AS rank
FROM projects
WHERE team_id = ${teamId}::uuid
AND search_vector @@ websearch_to_tsquery('english', ${query})
AND deleted_at IS NULL
ORDER BY rank DESC
LIMIT ${limit}
`;
}
async function searchTasks(teamId: string, query: string, limit: number) {
return db.$queryRaw<Array<{ id: string; title: string; rank: number }>>`
SELECT
t.id,
t.title,
ts_rank_cd(t.search_vector, websearch_to_tsquery('english', ${query})) AS rank
FROM tasks t
JOIN projects p ON p.id = t.project_id
WHERE p.team_id = ${teamId}::uuid
AND t.search_vector @@ websearch_to_tsquery('english', ${query})
AND t.deleted_at IS NULL
ORDER BY rank DESC
LIMIT ${limit}
`;
}
async function searchMembers(teamId: string, query: string, limit: number) {
return db.$queryRaw<Array<{ id: string; title: string; rank: number }>>`
SELECT
u.id,
u.name AS title,
1.0 AS rank
FROM team_members tm
JOIN users u ON u.id = tm.user_id
WHERE tm.team_id = ${teamId}::uuid
AND (
u.name ILIKE ${`%${query}%`}
OR u.email ILIKE ${`%${query}%`}
)
LIMIT ${limit}
`;
}
async function recordSearchEvent(data: {
teamId: string;
userId: string;
query: string;
resultCount: number;
}) {
await db.searchEvent.create({ data });
}
π SaaS MVP in 8 Weeks β Seriously
We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment β all handled by one senior team.
- Week 1β2: Architecture design + wireframes
- Week 3β6: Core features built + tested
- Week 7β8: Launch-ready on AWS/Vercel with CI/CD
- Post-launch: Maintenance plans from month 3
Recent Searches (LocalStorage + Server)
// lib/search/recent-searches.ts
const STORAGE_KEY = "recent_searches";
const MAX_RECENT = 8;
export interface RecentSearch {
query: string;
timestamp: number;
}
export function getRecentSearches(): RecentSearch[] {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
export function addRecentSearch(query: string) {
const trimmed = query.trim();
if (!trimmed) return;
const recent = getRecentSearches().filter(
(r) => r.query.toLowerCase() !== trimmed.toLowerCase()
);
const updated = [{ query: trimmed, timestamp: Date.now() }, ...recent].slice(
0,
MAX_RECENT
);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
} catch { /* storage quota */ }
}
export function clearRecentSearches() {
localStorage.removeItem(STORAGE_KEY);
}
React Autocomplete Component
// components/SearchAutocomplete/SearchAutocomplete.tsx
"use client";
import { useState, useRef, useEffect, useCallback, useId } from "react";
import { useRouter } from "next/navigation";
import { Search, Clock, FileText, CheckSquare, User, X } from "lucide-react";
import { useDebounce } from "@/hooks/useDebounce";
import { getRecentSearches, addRecentSearch, clearRecentSearches } from "@/lib/search/recent-searches";
interface SearchResult {
id: string;
type: "project" | "task" | "member";
title: string;
description?: string;
rank: number;
}
interface SearchResponse {
results: SearchResult[];
query: string;
}
const TYPE_ICONS = {
project: FileText,
task: CheckSquare,
member: User,
};
const TYPE_LABELS = {
project: "Project",
task: "Task",
member: "Member",
};
function useSearch(query: string) {
const [results, setResults] = useState<SearchResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
if (!query || query.length < 2) {
setResults([]);
return;
}
// Cancel previous request
abortRef.current?.abort();
abortRef.current = new AbortController();
setIsLoading(true);
fetch(`/api/search?q=${encodeURIComponent(query)}&limit=8`, {
signal: abortRef.current.signal,
})
.then((r) => r.json() as Promise<SearchResponse>)
.then((data) => setResults(data.results))
.catch((err) => {
if (err.name !== "AbortError") console.error("Search error:", err);
})
.finally(() => setIsLoading(false));
return () => abortRef.current?.abort();
}, [query]);
return { results, isLoading };
}
export function SearchAutocomplete() {
const router = useRouter();
const inputId = useId();
const listboxId = useId();
const [inputValue, setInputValue] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const [recentSearches, setRecentSearches] = useState<{ query: string; timestamp: number }[]>([]);
const debouncedQuery = useDebounce(inputValue, 250);
const { results, isLoading } = useSearch(debouncedQuery);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Load recent searches when opening
useEffect(() => {
if (isOpen) {
setRecentSearches(getRecentSearches());
}
}, [isOpen]);
// Close on outside click
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
setActiveIndex(-1);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const showRecent = inputValue.length < 2 && recentSearches.length > 0;
const items: Array<{ id: string; label: string; type?: string; href?: string }> = showRecent
? recentSearches.map((r) => ({ id: `recent:${r.query}`, label: r.query }))
: results.map((r) => ({
id: r.id,
label: r.title,
type: r.type,
href: `/${r.type}s/${r.id}`,
}));
function handleSelect(item: (typeof items)[number]) {
if (item.href) {
addRecentSearch(inputValue || item.label);
setInputValue("");
setIsOpen(false);
router.push(item.href);
} else {
// Recent search β re-run query
setInputValue(item.label);
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if (!isOpen) {
if (e.key === "ArrowDown" || e.key === "Enter") {
setIsOpen(true);
setActiveIndex(0);
}
return;
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setActiveIndex((prev) => Math.min(prev + 1, items.length - 1));
break;
case "ArrowUp":
e.preventDefault();
setActiveIndex((prev) => Math.max(prev - 1, -1));
break;
case "Enter":
e.preventDefault();
if (activeIndex >= 0 && items[activeIndex]) {
handleSelect(items[activeIndex]);
} else if (inputValue.trim()) {
addRecentSearch(inputValue);
router.push(`/search?q=${encodeURIComponent(inputValue)}`);
setIsOpen(false);
}
break;
case "Escape":
setIsOpen(false);
setActiveIndex(-1);
inputRef.current?.blur();
break;
case "Tab":
setIsOpen(false);
break;
}
}
const handleClearRecent = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
clearRecentSearches();
setRecentSearches([]);
}, []);
return (
<div ref={containerRef} className="relative w-full max-w-lg">
{/* Input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<input
ref={inputRef}
id={inputId}
type="search"
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
setIsOpen(true);
setActiveIndex(-1);
}}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder="Search projects, tasks, people..."
className="w-full pl-9 pr-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
role="combobox"
aria-autocomplete="list"
aria-expanded={isOpen && items.length > 0}
aria-controls={listboxId}
aria-activedescendant={activeIndex >= 0 ? `${listboxId}-${activeIndex}` : undefined}
autoComplete="off"
/>
{isLoading && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
</div>
)}
</div>
{/* Dropdown */}
{isOpen && (inputValue.length > 0 || recentSearches.length > 0) && (
<div
id={listboxId}
role="listbox"
className="absolute top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-50 overflow-hidden"
>
{showRecent && (
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-100">
<span className="text-xs font-medium text-gray-400 uppercase tracking-wide">Recent</span>
<button
onClick={handleClearRecent}
className="text-xs text-gray-400 hover:text-gray-600"
>
Clear
</button>
</div>
)}
{items.length > 0 ? (
<ul>
{items.map((item, index) => {
const Icon = showRecent
? Clock
: TYPE_ICONS[item.type as keyof typeof TYPE_ICONS] ?? FileText;
return (
<li
key={item.id}
id={`${listboxId}-${index}`}
role="option"
aria-selected={index === activeIndex}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => handleSelect(item)}
className={`flex items-center gap-3 px-3 py-2.5 cursor-pointer transition-colors ${
index === activeIndex ? "bg-blue-50 text-blue-900" : "hover:bg-gray-50"
}`}
>
<Icon className="h-4 w-4 flex-shrink-0 text-gray-400" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{item.label}</p>
</div>
{item.type && (
<span className="text-xs text-gray-400 flex-shrink-0">
{TYPE_LABELS[item.type as keyof typeof TYPE_LABELS]}
</span>
)}
</li>
);
})}
</ul>
) : debouncedQuery.length >= 2 && !isLoading ? (
<div className="px-3 py-6 text-center text-sm text-gray-400">
No results for <strong className="text-gray-600">"{debouncedQuery}"</strong>
</div>
) : null}
{/* View all results link */}
{debouncedQuery.length >= 2 && results.length > 0 && (
<div
className="border-t border-gray-100 px-3 py-2 cursor-pointer hover:bg-gray-50"
onClick={() => {
addRecentSearch(inputValue);
router.push(`/search?q=${encodeURIComponent(inputValue)}`);
setIsOpen(false);
}}
>
<p className="text-xs text-blue-600 font-medium">
View all results for "{debouncedQuery}" β
</p>
</div>
)}
</div>
)}
</div>
);
}
π‘ The Difference Between a SaaS Demo and a SaaS Business
Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments β with architecture that does not need to be rewritten at 1,000 users.
- Multi-tenant PostgreSQL with row-level security
- Stripe subscriptions, usage billing, annual plans
- SOC2-ready infrastructure from day one
- We own zero equity β you own everything
Debounce Hook
// hooks/useDebounce.ts
import { useState, useEffect } from "react";
export function useDebounce<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
Search Analytics Dashboard Query
-- Top 20 searched terms (last 30 days) with zero-result rate
SELECT
query,
COUNT(*) AS total_searches,
ROUND(AVG(result_count), 1) AS avg_results,
SUM(CASE WHEN result_count = 0 THEN 1 ELSE 0 END) AS zero_result_count,
ROUND(
100.0 * SUM(CASE WHEN result_count = 0 THEN 1 ELSE 0 END) / COUNT(*),
1
) AS zero_result_pct,
COUNT(selected_result_id) AS click_count,
ROUND(
100.0 * COUNT(selected_result_id) / COUNT(*),
1
) AS click_through_rate
FROM search_events
WHERE team_id = $1
AND created_at > now() - INTERVAL '30 days'
GROUP BY query
HAVING COUNT(*) >= 5
ORDER BY total_searches DESC
LIMIT 20;
This query reveals:
- High volume + 0% CTR: users can't find what they want β improve result relevance
- High zero-result rate: content gap β create or index the missing content
- High CTR: working well β protect these search paths
Cost and Timeline Estimates
| Component | Timeline | Cost (USD) |
|---|---|---|
| PostgreSQL full-text search setup | 0.5β1 day | $400β$800 |
| Search API (multi-type, ranked) | 1β2 days | $800β$1,600 |
| Recent searches (localStorage) | 0.5 day | $300β$500 |
| React autocomplete with ARIA | 2β3 days | $1,600β$2,500 |
| Search analytics + dashboard | 1β2 days | $800β$1,600 |
| Full search system | 1.5β2 weeks | $6,000β$10,000 |
See Also
- PostgreSQL Full-Text Search β Full-text indexing deep dive
- React Hook Form + Zod β Form handling in React
- SaaS Activity Feed β Real-time feed architecture
- Next.js Performance Optimization β Optimizing Next.js for speed
Working With Viprasol
We build search systems for SaaS products β from PostgreSQL full-text through Elasticsearch at scale. Our team has shipped search experiences for products with millions of records and thousands of daily active users.
What we deliver:
- Multi-entity search with weighted ranking
- Autocomplete UI with full keyboard accessibility and ARIA compliance
- Search analytics to track zero-result queries and click-through rates
- Elasticsearch or Typesense migration when PostgreSQL reaches its limits
Explore our SaaS development services or contact us to add powerful search to your product.
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.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours β fast.
Free consultation β’ No commitment β’ Response within 24 hours
Add AI automation to your SaaS product?
Viprasol builds custom AI agent crews that plug into any SaaS workflow β automating repetitive tasks, qualifying leads, and responding across every channel your customers use.