Building Search With Autocomplete: Frontend to Backend
Implement search autocomplete from end to end — debounced input, API design, result ranking, keyboard navigation, and the UX details that make search feel great.
Strategic Systems Architect & Enterprise Software Developer
Search is the feature where users have the least patience. They expect results as they type, relevant suggestions before they finish their query, and instant navigation to what they find. A search bar that requires clicking a submit button and loading a results page feels broken in 2025 — not because it is broken, but because users have been trained by Google, Spotlight, and command palettes to expect better.
Building autocomplete search that meets these expectations requires coordinating frontend UX, API performance, and relevance ranking. Here is how I approach it end to end.
Debounced Input and Request Management
The foundation is a text input that triggers API requests as the user types, with enough debouncing to avoid overwhelming the server. The wrong approach is firing a request on every keystroke. The right approach is debouncing by 200-300 milliseconds and canceling in-flight requests when new input arrives.
// composables/useSearch.ts
export function useSearch() {
const query = ref('')
const results = ref<SearchResult[]>([])
const loading = ref(false)
let abortController: AbortController | null = null
const search = useDebounceFn(async (term: string) => {
if (term.length < 2) {
results.value = []
return
}
// Cancel previous request
abortController?.abort()
abortController = new AbortController()
loading.value = true
try {
const data = await $fetch<SearchResult[]>('/api/search', {
query: { q: term },
signal: abortController.signal,
})
results.value = data
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return
results.value = []
} finally {
loading.value = false
}
}, 250)
watch(query, (value) => search(value))
return { query, results, loading }
}
The AbortController is essential. Without it, slow responses from earlier keystrokes can arrive after faster responses from later keystrokes, causing results to flash incorrectly. Aborting the previous request guarantees that only the latest query's results are displayed.
The minimum query length (2 characters here) prevents the server from executing overly broad searches that return too many results to be useful. For some datasets, 3 characters is a better minimum. This threshold should be tuned based on your data — if your dataset has many two-character entries (like US state codes), lower it.
Keyboard Navigation and Accessibility
A search autocomplete is a composite widget that needs careful keyboard handling. The input captures text, and the dropdown results need arrow key navigation:
<script setup lang="ts">
const { query, results, loading } = useSearch()
const activeIndex = ref(-1)
const listboxId = 'search-results'
Function handleKeydown(event: KeyboardEvent) {
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
activeIndex.value = Math.min(activeIndex.value + 1, results.value.length - 1)
break
case 'ArrowUp':
event.preventDefault()
activeIndex.value = Math.max(activeIndex.value - 1, -1)
break
case 'Enter':
if (activeIndex.value >= 0) {
event.preventDefault()
selectResult(results.value[activeIndex.value])
}
break
case 'Escape':
results.value = []
activeIndex.value = -1
break
}
}
// Reset active index when results change
watch(results, () => { activeIndex.value = -1 })
</script>
<template>
<div class="relative">
<input
v-model="query"
type="search"
role="combobox"
aria-expanded="results.length > 0"
aria-controls="search-results"
:aria-activedescendant="activeIndex >= 0 ? `result-${activeIndex}` : undefined"
aria-autocomplete="list"
@keydown="handleKeydown"
placeholder="Search..."
/>
<ul
v-if="results.length"
:id="listboxId"
role="listbox"
class="absolute top-full left-0 right-0 mt-1 rounded-lg border bg-white shadow-lg"
>
<li
v-for="(result, index) in results"
:key="result.id"
:id="`result-${index}`"
role="option"
:aria-selected="index === activeIndex"
:class="index === activeIndex ? 'bg-brand-50' : ''"
class="cursor-pointer px-4 py-2 hover:bg-neutral-50"
@click="selectResult(result)"
@mouseenter="activeIndex = index"
>
<SearchResultItem :result="result" :query="query" />
</li>
</ul>
</div>
</template>
The ARIA attributes follow the combobox pattern from the WAI-ARIA Authoring Practices. role="combobox" on the input, role="listbox" on the dropdown, role="option" on each result, and aria-activedescendant tracking the keyboard-highlighted option. Screen readers announce the highlighted result as the user arrows through the list without moving DOM focus from the input.
The mouseenter handler syncs hover state with keyboard state — if the user switches from keyboard to mouse mid-interaction, the highlight follows the cursor. This detail prevents the confusing state where the keyboard highlight is on one item and the mouse hover is on another.
Query Highlighting and Result Display
Highlighting the matching portion of each result helps users confirm they are finding what they expect. The implementation splits the result text at match boundaries and wraps the matching segments:
<!-- components/SearchResultItem.vue -->
<script setup lang="ts">
interface Props {
result: SearchResult
query: string
}
Const props = defineProps<Props>()
Const highlightedTitle = computed(() => {
if (!props.query) return props.result.title
const regex = new RegExp(`(${escapeRegex(props.query)})`, 'gi')
return props.result.title.replace(regex, '<mark class="bg-brand-100 text-brand-900">$1</mark>')
})
</script>
<template>
<div>
<span v-html="highlightedTitle" />
<span class="block text-sm text-neutral-500">{{ result.category }}</span>
</div>
</template>
Escape the query string before using it in a regex — user input can contain special regex characters that would throw errors. A simple escapeRegex function that prepends backslashes to special characters prevents this.
Group results by category when the search spans multiple content types. "3 blog posts, 2 products, 1 user" is more scannable than a flat list of 6 results. Each category group should have a heading that is not selectable via keyboard navigation — only the results themselves should be options in the listbox.
API Design for Fast Autocomplete
The search API must respond in under 100 milliseconds for the autocomplete to feel instant. This constraint shapes the backend architecture. Full-text search on a large database table will not meet that target without indexing.
For PostgreSQL, tsvector columns with GIN indexes handle full-text search efficiently. For smaller datasets (under 100,000 records), trigram indexes (pg_trgm extension) provide fuzzy matching that tolerates typos. For larger datasets or complex relevance requirements, dedicated search engines like Meilisearch or Typesense offer sub-10ms query times.
Limit results to 5-8 items. Autocomplete is a suggestion mechanism, not a full search results page. Fewer results mean less data transferred, faster rendering, and less cognitive load on the user. If the user needs more results, they press Enter to see the full search results page with pagination and filters.
The API should return just enough data for rendering: title, category, URL, and optionally a short excerpt. Do not return full content bodies for autocomplete results — the transfer size adds latency that defeats the purpose.
Cache aggressively. Search queries follow a power-law distribution — a small number of queries account for most traffic. Caching the top 1,000 queries in memory eliminates database hits for the majority of autocomplete requests. Invalidate the cache when the underlying data changes, not on a timer.