Search autocomplete improves UX by showing suggestions as users type. Here is how to build one with proper debouncing, keyboard navigation, and accessibility.
The Hook
// hooks/use-debounce.ts
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
The Search API
// app/api/search/route.ts
import { NextRequest, NextResponse } from "next/server";
interface SearchResult {
id: string;
title: string;
description: string;
url: string;
category: string;
}
const data: SearchResult[] = [
{ id: "1", title: "Web Design Services", description: "Custom website design for businesses", url: "/services/web-design", category: "Services" },
{ id: "2", title: "Mobile App Development", description: "iOS and Android applications", url: "/services/mobile-apps", category: "Services" },
{ id: "3", title: "About Us", description: "Learn about our team and mission", url: "/about", category: "Pages" },
// ... more entries
];
export async function GET(request: NextRequest) {
const query = request.nextUrl.searchParams.get("q")?.toLowerCase().trim();
if (!query || query.length < 2) {
return NextResponse.json({ results: [] });
}
const results = data
.filter(
(item) =>
item.title.toLowerCase().includes(query) ||
item.description.toLowerCase().includes(query)
)
.slice(0, 8);
return NextResponse.json({ results });
}
The Autocomplete Component
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useDebounce } from "@/hooks/use-debounce";
import { useRouter } from "next/navigation";
interface SearchResult {
id: string;
title: string;
description: string;
url: string;
category: string;
}
function highlightMatch(text: string, query: string) {
if (!query) return text;
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi");
const parts = text.split(regex);
return parts.map((part, i) =>
regex.test(part) ? (
<mark key={i} className="bg-yellow-200 dark:bg-yellow-800 rounded-sm px-0.5">
{part}
</mark>
) : (
part
)
);
}
export function SearchAutocomplete() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const [loading, setLoading] = useState(false);
const debouncedQuery = useDebounce(query, 300);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const router = useRouter();
const listboxId = "search-listbox";
// Fetch results
useEffect(() => {
if (debouncedQuery.length < 2) {
setResults([]);
setIsOpen(false);
return;
}
const controller = new AbortController();
async function fetchResults() {
setLoading(true);
try {
const res = await fetch(
`/api/search?q=${encodeURIComponent(debouncedQuery)}`,
{ signal: controller.signal }
);
const data = await res.json();
setResults(data.results);
setIsOpen(data.results.length > 0);
setActiveIndex(-1);
} catch (e) {
if ((e as Error).name !== "AbortError") {
setResults([]);
}
} finally {
setLoading(false);
}
}
fetchResults();
return () => controller.abort();
}, [debouncedQuery]);
const selectResult = useCallback(
(result: SearchResult) => {
setQuery(result.title);
setIsOpen(false);
router.push(result.url);
},
[router]
);
// Keyboard navigation
function handleKeyDown(e: React.KeyboardEvent) {
if (!isOpen) {
if (e.key === "ArrowDown" && results.length > 0) {
setIsOpen(true);
setActiveIndex(0);
e.preventDefault();
}
return;
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setActiveIndex((prev) => (prev + 1) % results.length);
break;
case "ArrowUp":
e.preventDefault();
setActiveIndex((prev) => (prev - 1 + results.length) % results.length);
break;
case "Enter":
e.preventDefault();
if (activeIndex >= 0 && results[activeIndex]) {
selectResult(results[activeIndex]);
}
break;
case "Escape":
setIsOpen(false);
setActiveIndex(-1);
break;
}
}
// Scroll active item into view
useEffect(() => {
if (activeIndex >= 0 && listRef.current) {
const activeElement = listRef.current.children[activeIndex] as HTMLElement;
activeElement?.scrollIntoView({ block: "nearest" });
}
}, [activeIndex]);
// Close on click outside
useEffect(() => {
function handleClick(e: MouseEvent) {
if (
!inputRef.current?.contains(e.target as Node) &&
!listRef.current?.contains(e.target as Node)
) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
// Group results by category
const grouped = results.reduce<Record<string, SearchResult[]>>((acc, result) => {
if (!acc[result.category]) acc[result.category] = [];
acc[result.category].push(result);
return acc;
}, {});
return (
<div className="relative w-full max-w-md">
<div className="relative">
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
ref={inputRef}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => results.length > 0 && setIsOpen(true)}
placeholder="Search..."
role="combobox"
aria-expanded={isOpen}
aria-controls={listboxId}
aria-activedescendant={
activeIndex >= 0 ? `search-option-${results[activeIndex]?.id}` : undefined
}
aria-autocomplete="list"
className="w-full pl-10 pr-10 py-2 border rounded-md bg-background"
/>
{loading && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="h-4 w-4 border-2 border-muted-foreground border-t-transparent rounded-full animate-spin" />
</div>
)}
</div>
{isOpen && (
<ul
ref={listRef}
id={listboxId}
role="listbox"
className="absolute top-full mt-1 w-full bg-background border rounded-md shadow-lg max-h-80 overflow-y-auto z-50"
>
{Object.entries(grouped).map(([category, items]) => (
<li key={category} role="presentation">
<div className="px-3 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider bg-muted/50">
{category}
</div>
<ul role="group" aria-label={category}>
{items.map((result) => {
const index = results.indexOf(result);
return (
<li
key={result.id}
id={`search-option-${result.id}`}
role="option"
aria-selected={index === activeIndex}
onClick={() => selectResult(result)}
onMouseEnter={() => setActiveIndex(index)}
className={`px-3 py-2 cursor-pointer ${
index === activeIndex ? "bg-primary/10" : "hover:bg-muted"
}`}
>
<div className="font-medium text-sm">
{highlightMatch(result.title, debouncedQuery)}
</div>
<div className="text-xs text-muted-foreground">
{highlightMatch(result.description, debouncedQuery)}
</div>
</li>
);
})}
</ul>
</li>
))}
</ul>
)}
{/* Screen reader status */}
<div role="status" aria-live="polite" className="sr-only">
{loading
? "Searching..."
: results.length > 0
? `${results.length} results found`
: query.length >= 2
? "No results found"
: ""}
</div>
</div>
);
}
Usage
// In any page or header
import { SearchAutocomplete } from "@/components/SearchAutocomplete";
export default function Header() {
return (
<header className="flex items-center justify-between px-4 py-3 border-b">
<span className="font-bold">My Site</span>
<SearchAutocomplete />
</header>
);
}
Key Features
- Debounced API calls to avoid hammering the server on every keystroke
- Keyboard navigation with arrow keys, Enter to select, Escape to close
- ARIA combobox pattern for screen reader support
- Result highlighting to show matching text
- Grouped results by category
- AbortController to cancel stale requests
- Live region to announce result counts to screen readers
Need Search for Your Website?
We build fast, accessible search experiences with autocomplete, filtering, and faceted search. Contact us to add search to your site.