Back to Blog

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.

Viprasol Tech Team
January 12, 2027
13 min read

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

ComponentTimelineCost (USD)
PostgreSQL full-text search setup0.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 ARIA2–3 days$1,600–$2,500
Search analytics + dashboard1–2 days$800–$1,600
Full search system1.5–2 weeks$6,000–$10,000

See Also


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.

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours β€” fast.

Free consultation β€’ No commitment β€’ Response within 24 hours

Viprasol Β· AI Agent Systems

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.