Skip to content
SearchJet
React Autocomplete Search Component

Search-as-You-Type Autocomplete for React

A debounced, keyboard-navigable, accessible autocomplete dropdown backed by a hosted search API. Debounce, highlighting, and a11y included — you bring the styling.

Debounced by default
Typo-tolerant matching
Full a11y + keyboard support

See It Working — Type Right Here

Try “wireless headphons” (typo included). Keyboard arrows work too.

Building Autocomplete from Scratch is a Trap

Debouncing, request races, keyboard navigation, accessibility, empty states — that's a week of work before you even test. The copy-paste code below handles it all.

Naive onChange = API Spam

Firing a fetch on every keystroke hammers your API and renders stale results. A production autocomplete needs debouncing, request cancellation, and race-condition guards.

Keyboard Navigation is Hard

Arrow-up / arrow-down to move through suggestions, Enter to select, Escape to dismiss — building accessible combobox behavior from scratch is notoriously fiddly and a common a11y failure.

Empty Results, Empty UX

Without a server-side "no match" strategy, your users hit dead ends. A great autocomplete also suggests popular queries when there are no exact hits.

Copy-Paste Implementation

Two Ways to Build It

A complete component that calls the API straight from the browser, or a headless hook that goes through your own server route.

Complete <Autocomplete /> (browser fetch)

Autocomplete.jsx
import { useState, useEffect } from 'react';

// No SDK to install — one HTTP request to the SearchJet REST API.
const API = 'https://app.searchjetengine.com/api/v1/search';
const SEARCH_KEY = ''; // search key only — never the private key

// Wrap the first match of the query in <mark>
function Highlight({ text = '', query }) {
  const q = query.trim().toLowerCase();
  const i = q ? text.toLowerCase().indexOf(q) : -1;
  if (i === -1) return text;
  return <>{text.slice(0, i)}<mark>{text.slice(i, i + q.length)}</mark>{text.slice(i + q.length)}</>;
}

export default function Autocomplete() {
  const [query, setQuery] = useState('');
  const [hits, setHits] = useState([]);
  const [active, setActive] = useState(-1);

  useEffect(() => {
    const q = query.trim();
    if (!q) { setHits([]); return; }

    const controller = new AbortController();
    const timer = setTimeout(async () => {
      try {
        const res = await fetch(`${API}?q=${encodeURIComponent(q)}&limit=6`, {
          headers: { Authorization: `Bearer ${SEARCH_KEY}` },
          signal: controller.signal,
        });
        const data = await res.json();
        setHits(data.results ?? []);       // `results` is the array of hits
        setActive(-1);
      } catch (err) {
        if (err.name !== 'AbortError') console.error(err);
      }
    }, 150);                                // debounce

    return () => { clearTimeout(timer); controller.abort(); };  // cancel stale requests
  }, [query]);

  const go = (hit) => { window.location.href = hit.url; };

  const onKeyDown = (e) => {
    if (!hits.length) return;
    if (e.key === 'ArrowDown') { e.preventDefault(); setActive((a) => (a + 1) % hits.length); }
    if (e.key === 'ArrowUp') { e.preventDefault(); setActive((a) => (a <= 0 ? hits.length - 1 : a - 1)); }
    if (e.key === 'Enter' && active > -1) go(hits[active]);
    if (e.key === 'Escape') setHits([]);
  };

  return (
    <div className="autocomplete">
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        onKeyDown={onKeyDown}
        role="combobox"
        aria-expanded={hits.length > 0}
        aria-controls="suggestions"
        aria-activedescendant={active > -1 ? `suggestion-${active}` : undefined}
        placeholder="Search docs, products…"
      />
      {hits.length > 0 && (
        <ul id="suggestions" role="listbox">
          {hits.map((hit, i) => (
            <li
              key={hit.id}
              id={`suggestion-${i}`}
              role="option"
              aria-selected={i === active}
              onMouseDown={() => go(hit)}
            >
              <Highlight text={hit.title} query={query} />
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Headless hook (debounce + races handled)

useSuggestions.js
import { useState, useEffect } from 'react';

/**
 * Headless autocomplete — full control, zero assumptions.
 * Calls YOUR /api/search route (see the Next.js headless guide),
 * which forwards to GET /search with the key from process.env.
 */
function useSuggestions(value) {
  const [suggestions, setSuggestions] = useState([]);
  const [status, setStatus] = useState('idle');

  useEffect(() => {
    const q = value.trim();
    if (!q) {
      setSuggestions([]);
      setStatus('idle');
      return;
    }

    const controller = new AbortController();  // race-condition guard
    const timer = setTimeout(async () => {
      setStatus('loading');
      try {
        const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
          signal: controller.signal,
        });
        const data = await res.json();
        setSuggestions(data.results ?? []);     // array of hits
        setStatus('success');
      } catch (err) {
        if (err.name !== 'AbortError') setStatus('error');
      }
    }, 150);                                     // debounce

    return () => {
      controller.abort();                        // drop stale responses
      clearTimeout(timer);
    };
  }, [value]);

  return { suggestions, status };
}

export function AutocompleteHeadless() {
  const [query, setQuery] = useState('');
  const [active, setActive] = useState(-1);
  const { suggestions } = useSuggestions(query);

  const onKeyDown = (e) => {
    if (!suggestions.length) return;
    if (e.key === 'ArrowDown') setActive((a) => (a + 1) % suggestions.length);
    if (e.key === 'ArrowUp') setActive((a) => (a <= 0 ? suggestions.length - 1 : a - 1));
    if (e.key === 'Enter' && active > -1) {
      window.location.href = suggestions[active].url;
    }
  };

  return (
    <input
      value={query}
      onChange={(e) => { setQuery(e.target.value); setActive(-1); }}
      onKeyDown={onKeyDown}
      role="combobox"
      aria-expanded={suggestions.length > 0}
      placeholder="Type to search…"
    />
  );
}

From Zero to Autocomplete in 4 Steps

1

Get your free API key

Generate a key right on this page — the widget injects it straight into your clipboard-ready code.

2

No package to install

There is no SDK — the component below calls GET /search with fetch. Copy it into your project as-is.

3

Render <Autocomplete />

Debounced at 150ms, stale requests cancelled with AbortController, and Enter or click navigates to the hit URL. Change the limit, placeholder, or navigation to suit your app.

4

Ship it

Style it with your own CSS. Keyboard navigation and combobox ARIA roles are already in the code. Done in minutes.

Frequently Asked Questions

How do I debounce search input in React?
Wrap your search call in a setTimeout that is cleared on every keystroke (150ms works well), and abort the previous request with AbortController so stale responses never render. Both examples below show the full pattern.
Does the autocomplete support keyboard navigation?
Yes. In the example component, arrow keys move the active suggestion, Enter selects, Escape closes the dropdown, and the input uses combobox / listbox ARIA roles for screen readers.
Can I highlight matched text in the suggestions?
Yes. The example component wraps the matched part of each title in a <mark> tag on the client, so it works with any field you render (title, excerpt, or your own custom fields).
Can I use autocomplete without a React library?
Absolutely. Search is a single HTTP request (GET /search), so it works with vanilla JS, Vue, Svelte, or any HTTP client — see the Vanilla JS site search guide for a framework-free version.
Is the search API fast enough for search-as-you-type?
SearchJet's sub-150ms queries are designed for search-as-you-type. Queries are typo-tolerant, so "wireles headphons" still returns wireless headphones.

Ship a Production-Ready Autocomplete Today

Free forever plan. 10,000 searches/month. No credit card.

More Search Recipes