10.4 — Search playbook¶
Module 10 · Lesson 4 · 🟡 Intermediate · ~25 min
High intent, high abandonment, and usually the slowest page on the site.
Profile¶
| Share of sessions | ~12% |
| Conversion rate | 2–4× the site average — searchers know what they want |
| Cacheability | Top ~2K queries: high. Long tail: none |
| LCP element | First result tile, or the results heading |
| Dominant risks | Query latency, typeahead INP, zero‑result cost |
| Rendering strategy | Streamed shell + dynamic results, edge cache for popular queries |
Budgets¶
| Metric | Target |
|---|---|
| LCP | ≤ 2.5 s |
| INP (typeahead) | ≤ 100 ms |
| TTFB | ≤ 500 ms |
| Results visible | ≤ 1.2 s |
| JS (gz) | ≤ 240 KB |
Note the extra metric. For search, "results visible" matters more than LCP — a searcher watching an empty results area is the abandonment moment.
The structure¶
// app/search/page.tsx
export default async function SearchPage({ searchParams }) {
const { q = '', ...filters } = await searchParams;
return (
<main>
{/* Static shell: paints immediately. TTFB is NOT gated on the query. */}
<SearchHeader query={q} />
{q ? (
// key={q} so a genuinely new query shows the skeleton;
// refinements inside a transition keep the old results visible
<Suspense key={q} fallback={<ResultsSkeleton count={12} />}>
<SearchResults query={q} filters={filters} />
</Suspense>
) : (
<SearchLanding /> // popular categories, recent searches — all cached
)}
</main>
);
}
async function SearchResults({ query, filters }: Props) {
const results = await searchProducts(query, filters);
if (!results.hits.length) {
return <NoResults query={query} suggestions={results.didYouMean} popular={results.fallback} />;
}
return (
<>
<ResultsHeader count={results.total} query={query} />
<div className="grid lg:grid-cols-[280px_1fr] lg:gap-8">
<Suspense fallback={<FacetsSkeleton />}>
<SearchFacets query={query} filters={filters} />
</Suspense>
<ProductGrid products={results.hits} />
</div>
</>
);
}
The key decision: the shell streams immediately so TTFB is ~80 ms regardless of how slow the search service is. The user sees the header, their query echoed in the box, and a skeleton — not a blank page.
Typeahead¶
The most frequent interaction on many commerce sites. Full implementation in 7.3; the essentials:
'use client';
export function SearchTypeahead() {
const [query, setQuery] = useState('');
const [debounced, setDebounced] = useState('');
const deferred = useDeferredValue(query);
const abortRef = useRef<AbortController>();
useEffect(() => {
const t = setTimeout(() => setDebounced(query.trim()), 180); // debounce the NETWORK
return () => clearTimeout(t);
}, [query]);
const { data } = useSWR(
debounced.length >= 2 ? ['suggest', debounced] : null,
async ([, q]) => {
abortRef.current?.abort(); // cancel superseded
abortRef.current = new AbortController();
const res = await fetch(`/api/suggest?q=${encodeURIComponent(q)}`, {
signal: abortRef.current.signal,
});
return res.json();
},
{ keepPreviousData: true, dedupingInterval: 2000 },
);
return (
<div className="relative">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
role="combobox"
aria-expanded={!!data?.suggestions?.length}
aria-controls="suggest-list"
aria-autocomplete="list"
enterKeyHint="search"
/>
<ul id="suggest-list" role="listbox"
style={{ opacity: query !== deferred ? 0.7 : 1 }}>
{data?.suggestions?.slice(0, 8).map((s: Suggestion) => (
<li key={s.id} role="option" aria-selected={false}>
<a href={s.url}>
{s.type === 'product' && <img src={s.thumb} alt="" width={40} height={53} loading="lazy" />}
<span>{s.label}</span>
</a>
</li>
))}
</ul>
</div>
);
}
The five rules again, because they're the whole game: 1. Debounce the network (150–250 ms), defer the render — both, not one. 2. Minimum 2 characters. 3. Cap at 8 suggestions. 4. Abort superseded requests. 5. Edge‑cache the suggestion endpoint hard — prefixes repeat across users.
// app/api/suggest/route.ts
export async function GET(req: NextRequest) {
const q = req.nextUrl.searchParams.get('q')?.toLowerCase().trim() ?? '';
if (q.length < 2 || q.length > 64) return Response.json({ suggestions: [] });
const suggestions = await searchClient.suggest(q, { limit: 8 });
return Response.json({ suggestions }, {
headers: { 'Cache-Control': 'public, s-maxage=600, stale-while-revalidate=86400' },
});
}
Zero results¶
Zero‑result pages are often 10–20% of search traffic, and they're usually the slowest — the engine exhausted every fallback strategy before giving up.
function NoResults({ query, suggestions, popular }: Props) {
return (
<div className="py-12">
<h1 className="text-xl">No results for "{query}"</h1>
{suggestions?.length ? (
<p className="mt-3">
Did you mean{' '}
{suggestions.map((s, i) => (
<span key={s}>
{i > 0 && ', '}
<a href={`/search?q=${encodeURIComponent(s)}`} className="underline">{s}</a>
</span>
))}?
</p>
) : null}
{/* Cached popular products — never another expensive query */}
<section className="mt-10">
<h2>Popular right now</h2>
<ProductGrid products={popular} />
</section>
</div>
);
}
// Bound the fallback chain so zero-result queries don't cost 2 seconds
const results = await searchClient.search(query, {
typoTolerance: 'min',
removeStopWords: true,
timeout: 600,
});
if (!results.hits.length) {
return {
hits: [],
didYouMean: results.didYouMean?.slice(0, 3) ?? [],
fallback: await getCachedPopularProducts(), // cached, ~5ms
};
}
Caching search¶
Query distribution follows a steep power law:
Top 100 queries → 34% of searches
Top 1,000 queries → 61%
Top 10,000 queries → 79%
Long tail → 21%
Cache the head, don't try to cache the tail.
// Normalize the query so equivalent searches share a cache entry.
// "Wool Coat", "wool coat", and "wool coat " should all be one key.
function normalizeQuery(q: string): string {
return q.toLowerCase().trim().replace(/\s+/g, ' ').slice(0, 64);
}
// Popular queries: cacheable at the edge
const isPopular = POPULAR_QUERIES.has(normalizeQuery(q));
return new Response(html, {
headers: {
'Cache-Control': isPopular
? 'public, s-maxage=300, stale-while-revalidate=3600'
: 'private, no-store',
},
});
Refresh POPULAR_QUERIES from analytics weekly. It's a small set — the top 2,000 normalized
queries — and it converts a fifth of your search traffic from origin renders into edge hits.
Search UX that's also a performance win¶
| Practice | Performance effect |
|---|---|
| Show the query in the input on the results page | No re‑typing; fewer searches |
| Preserve filters when refining the query | Fewer round trips |
Recent searches from localStorage |
Zero server cost |
enterKeyHint="search" on mobile |
One fewer tap |
| Search‑as‑navigation for exact SKU matches | Skip the results page entirely |
| Link suggestions directly to the PDP | Skip a page load |
// If the query is an exact SKU or a single strong match, go straight to the PDP
const results = await searchProducts(query);
if (results.total === 1 && results.hits[0].score > 0.95) {
redirect(`/p/${results.hits[0].slug}`);
}
That last one is worth more than most of this page — it removes an entire page load from a high‑intent journey.
Diagnosis order¶
Search TTFB > 500ms?
├─ Is the shell blocked on the query? → stream it, [3.3]
├─ Is generateMetadata awaiting the search? → derive from the query string only
└─ Are popular queries cached at the edge? → normalize + allowlist
Results visible > 1.2s?
├─ Are facets computed for all 24 facets? → allowlist, [7.3]
├─ Is result hydration N+1? → batch, [7.3]
├─ Is personalized ranking on the critical path? → make it optional/streamed
└─ Is the zero-result fallback chain unbounded? → cap it
Typeahead INP > 100ms?
├─ Is the network call debounced? → 180ms
├─ Is the render deferred? → useDeferredValue
├─ Are superseded requests aborted? → AbortController
├─ Rendering more than 8 suggestions? → cap it
└─ Is the input re-rendering on results arrival? → separate the components
Checklist¶
- Shell streams; TTFB independent of query latency
-
key={q}on the results boundary for new queries; transitions for refinements - Typeahead: debounced network, deferred render, aborted requests, ≤ 8 suggestions
- Suggestion endpoint edge‑cached with SWR
- Query normalized for caching; popular query allowlist maintained weekly
- Facets allowlisted and capped
- Result hydration batched
- Zero‑result path bounded, falls back to cached popular products
- Exact‑match queries redirect straight to the PDP
- Recent searches from
localStorage, not the server -
enterKeyHint="search"and correctinputModeon mobile
Next: 10.5 Cart & checkout