Skip to content

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
  };
}

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 correct inputMode on mobile

Next: 10.5 Cart & checkout