No-results recommendations
When `found: 0`, do not show a blank page. Show curated bestsellers, did-you-mean suggestions, and a search-tip that recovers the user's flow.
A blank "no results" page is a dead end. This recipe replaces it with three complementary recoveries: a did-you-mean correction, popular categories, and a fallback bestseller list.
What you build
┌─────────────────────────────────────────┐
│ No results for "shooes" │
│ │
│ Did you mean: shoes │
│ │
│ Try a popular category: │
│ [Sneakers] [Boots] [Sandals] │
│ │
│ ── Bestsellers ── │
│ [...12 product cards...] │
└─────────────────────────────────────────┘Component
"use client";
import { useEffect, useState } from "react";
import { SearchClient } from "@aacsearch/client";
const client = new SearchClient({
baseUrl: process.env.NEXT_PUBLIC_AACSEARCH_BASE_URL!,
apiKey: process.env.NEXT_PUBLIC_AACSEARCH_SEARCH_KEY!,
indexSlug: "products",
});
export function NoResults({ query }: { query: string }) {
const [didYouMean, setDidYouMean] = useState<string | null>(null);
const [bestsellers, setBestsellers] = useState<Product[]>([]);
const [popularCategories, setPopularCategories] = useState<string[]>([]);
useEffect(() => {
(async () => {
// Fire all three rescue queries in parallel
const [spell, sellers, cats] = await Promise.all([
// 1. Spell-check the failing query
fetch(`/api/v1/projects/${process.env.NEXT_PUBLIC_AACSEARCH_ORG_ID}/spell-check`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.NEXT_PUBLIC_AACSEARCH_SEARCH_KEY}` },
body: JSON.stringify({ q: query }),
}).then((r) => r.json()),
// 2. Top 12 bestsellers
client.search({
q: "*",
sortBy: "sales_count:desc",
perPage: 12,
}),
// 3. Top 5 categories by product count (facet of "*")
client.search({
q: "*",
perPage: 0,
facetBy: "categories",
maxFacetValues: 5,
}),
]);
if (spell.suggestions?.[0] && spell.suggestions[0] !== query) {
setDidYouMean(spell.suggestions[0]);
}
setBestsellers(sellers.hits.map((h) => h.document));
const catFacet = cats.facetCounts.find((f) => f.field === "categories");
setPopularCategories(catFacet?.counts.map((c) => c.value) ?? []);
})();
}, [query]);
return (
<div className="no-results">
<h2>No results for "{query}"</h2>
{didYouMean && (
<p className="did-you-mean">
Did you mean <a href={`?q=${didYouMean}`}>{didYouMean}</a>?
</p>
)}
{popularCategories.length > 0 && (
<section>
<h3>Try a popular category</h3>
<div className="category-chips">
{popularCategories.map((cat) => (
<a key={cat} href={`/c/${cat.toLowerCase()}`} className="chip">
{cat}
</a>
))}
</div>
</section>
)}
{bestsellers.length > 0 && (
<section>
<h3>Bestsellers</h3>
<div className="product-grid">
{bestsellers.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
</section>
)}
</div>
);
}Track the failed query
Send a zero_results event so you can find recurring failures and add synonyms / content for them:
useEffect(() => {
if (query) {
fetch("/api/events/track", {
method: "POST",
body: JSON.stringify({
event: "zero_results",
properties: { query, indexSlug: "products" },
}),
});
}
}, [query]);Open Search → Analytics → "Top zero-result queries" weekly to triage.
Did-you-mean: server-side, not client
If your search page is server-rendered, do the spell-check before render and pass the suggestion as a prop. The user sees the corrected suggestion on first paint instead of a flash of "no results" followed by a delayed correction.
// app/search/page.tsx
const result = await client.search({ q: searchParams.q, queryBy: "title" });
if (result.found === 0) {
const spell = await fetch(`...spell-check`, { ... }).then((r) => r.json());
return <NoResults query={searchParams.q} suggestion={spell.suggestions?.[0]} />;
}Heuristics for "did you mean" UX
- Only show the suggestion if it actually returns results — re-run a search with the suggested query and check
found > 0. - If the suggestion is a single character difference, auto-redirect:
if (typoDistance === 1) router.replace(?q=${suggestion}). - For non-Latin scripts (Cyrillic, Arabic, CJK), spell-check accuracy varies. Test before enabling.
Empty category vs empty search
These warrant different treatments:
| Surface | Cause | Best response |
|---|---|---|
| Search results | User typed a query that matched nothing | Did-you-mean + popular cats + bestsellers |
PLP (/c/foo) | Category exists but is empty | Show "Coming soon" or redirect to parent category |
| 404 | Category does not exist at all | Standard 404 |
Distinguish by checking against the category list at build time:
const categories = await fetch("/api/categories").then((r) => r.json());
const exists = categories.some((c) => c.slug === slug);
if (!exists) notFound();Related
- Empty results — diagnose unexpected zero-result queries
- Click analytics —
zero_resultsevent - Dashboard → Relevance tuning — synonyms and curations
Click analytics
Track which results users actually click and which queries lead to conversions — feeds the AACsearch relevance tuner and your own funnel dashboards.
Fallback search
When AACsearch is unreachable (network blip, ongoing incident), degrade gracefully to a static catalog snapshot or a database query — never to a blank page.