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,
});
}modelsrestricts the search to the record types you can render. Pick yours from thenodeunion ofSearchResult. Knowledge-base articles aren't in the index; to search a help center, filter its articles in memory (Help Center).- Keep
firstsmall: every hit costs one moreget*request. - An empty result can also mean the index is unavailable:
searchreturns 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.