# Search-as-you-type

URL: https://developers.uidu.org/docs/recipes/search-as-you-type

> Debounced live search across events and stories, through a route handler that runs search on the server.

A search box that updates as the visitor types. The input is a Client Component, and it calls a
route handler that runs [`search`](https://developers.uidu.org/docs/domains/search.md) on the server. `search` returns references
(`__typename` + `id`), so the route also fetches what each result shows. Keeping all of it on the
server means the browser never holds a token, and the follow-up requests happen in one place.

## The route handler

```ts
// src/app/api/search/route.ts
import { getEvent, getStory, search } from '@uidu/client';
import { uidu } from '@/lib/uidu'; // server-only client

export type SearchHit = { id: string; type: 'Event' | 'Story'; title: string; href: string };

export async function GET(request: Request) {
  const q = new URL(request.url).searchParams.get('q')?.trim() ?? '';
  if (q.length < 2) return Response.json({ hits: [], total: 0 });

  const result = await search(uidu, {
    models: ['Event', 'Story'], // always pass models
    params: { term: q },
    first: 8,
  });
  const nodes = (result?.edges ?? []).flatMap((e) => (e?.node ? [e.node] : []));

  // Each node is only { __typename, id }: fetch the title from its own domain.
  const hits = await Promise.all(
    nodes.map(async (node): Promise<SearchHit | null> => {
      const href = (base: string) => `${base}/${encodeURIComponent(node.id)}`;
      switch (node.__typename) {
        case 'Event': {
          const event = await getEvent(uidu, { id: node.id });
          return event && { id: node.id, type: 'Event', title: event.name ?? '', href: href('/event') };
        }
        case 'Story': {
          const story = await getStory(uidu, { id: node.id });
          return story && { id: node.id, type: 'Story', title: story.name ?? '', href: href('/stories') };
        }
        default:
          return null;
      }
    }),
  );

  return Response.json({
    hits: hits.filter((h): h is SearchHit => h !== null),
    total: result?.totalCount ?? 0,
  });
}
```

* `models` restricts the search to the record types you can render. Pick yours from the `node`
  union of `SearchResult`. Knowledge-base articles aren't in the index; to search a help center,
  filter its articles in memory ([Help Center](https://developers.uidu.org/docs/domains/help-center.md)).
* Keep `first` small: every hit costs one more `get*` request.
* An empty result can also mean the index is unavailable: `search` returns no hits instead of
  throwing. The operators, sorting and pagination are covered on [Search](https://developers.uidu.org/docs/domains/search.md).

## The component

```tsx
// src/components/live-search.tsx
'use client';

import { useEffect, useState } from 'react';
import Link from 'next/link';
import type { SearchHit } from '@/app/api/search/route';

export function LiveSearch() {
  const [query, setQuery] = useState('');
  const [hits, setHits] = useState<SearchHit[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (query.trim().length < 2) {
      setHits([]);
      return;
    }

    const controller = new AbortController();
    const timer = setTimeout(async () => {
      setLoading(true);
      try {
        const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
          signal: controller.signal,
        });
        const data: { hits: SearchHit[] } = await res.json();
        setHits(data.hits);
      } catch (err) {
        if ((err as Error).name !== 'AbortError') throw err;
      } finally {
        if (!controller.signal.aborted) setLoading(false);
      }
    }, 200);

    return () => {
      clearTimeout(timer);
      controller.abort(); // a newer keystroke wins: drop the stale request
    };
  }, [query]);

  return (
    <div>
      <input
        type="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search…"
        aria-label="Search"
      />
      {loading && <span>Searching…</span>}
      <ul>
        {hits.map((hit) => (
          <li key={hit.id}>
            <Link href={hit.href}>{hit.title}</Link>
            <small> · {hit.type}</small>
          </li>
        ))}
      </ul>
    </div>
  );
}
```

## Why 200ms

It's shorter than the gap between keystrokes for most people, and long enough to skip requests
fired mid-word. If your visitors are far from the server, raise it to 300ms.

## Cancel stale requests

If the visitor types fast, results for earlier characters can arrive *after* later ones. The effect
above aborts the previous `fetch` whenever the query changes. The `AbortController` belongs to
`fetch` in the browser: `search` itself takes no signal, and the server finishes the search it
started.

## Group by record type

```tsx
const grouped = hits.reduce<Record<string, SearchHit[]>>((acc, hit) => {
  (acc[hit.type] ??= []).push(hit);
  return acc;
}, {});

return (
  <div>
    {Object.entries(grouped).map(([type, items]) => (
      <section key={type}>
        <h3>{type}</h3>
        <ul>
          {items.map((hit) => (
            <li key={hit.id}>
              <Link href={hit.href}>{hit.title}</Link>
            </li>
          ))}
        </ul>
      </section>
    ))}
  </div>
);
```

The server already returns hits in relevance order, and grouping keeps that order within each type.
