uidudevelopers
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 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

// 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).
  • 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.

The component

// 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

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.

On this page