Developer Demo
Facets & Autocomplete
Faceted drill-down filtering with live counts and type-ahead suggestions - both computed by Optimizely Graph in the same query as the results, no separate aggregation service needed.
Live demo#
Type banking or mortgage and pause - autocomplete suggests tags and page paths as you type. Then use the checkboxes to drill into the results: counts update because Graph recomputes facets on the filtered set with every query.
Category
No values yet.
Tags
No values yet.
Type at least two characters. Facet counts appear with the first results.
Calls /api/search?facets=1 and /api/search/autocomplete - facet counts are computed by Graph on the filtered result set, so they narrow as you drill down.
The facets field#
Facets are aggregations over the result set - each bucket is a distinct field value with the number of matching items. They come back in the same query as the results, so a search page with counts is still a single round-trip. Any field indexed with indexingType: "queryable" can facet; here category (an enum string) and tags (a string array) on ArticlePage.
# facets sits alongside items/total and returns aggregations with
# live counts. Facet fields must be indexed with
# indexingType: "queryable" (or be metadata fields, which always are).
#
# orderType: COUNT | VALUE - sort buckets by frequency or alphabetically
# orderBy: ASC | DESC
query SearchFaceted($query: String!, $categories: [String!], $tags: [String!]) {
ArticlePage(
where: {
_fulltext: { match: $query }
category: { in: $categories } # null variables are ignored -
tags: { in: $tags } # no filter until a facet is picked
}
orderBy: { _ranking: RELEVANCE }
limit: 10
) {
total
items {
_score
category
tags
_metadata { displayName url { default } }
}
facets {
category(orderType: COUNT, orderBy: DESC, limit: 10) { name count }
tags(orderType: COUNT, orderBy: DESC, limit: 12) { name count }
}
}
}Drill-down: facets feed the filter#
The pattern is a loop: facet values render as checkboxes, checked values go back into the where clause via in operators, and the next response returns both narrowed results and narrowed counts. Graph ignores filter operators whose variable is null, so one query handles every combination of active filters - no query building on the client.
// Drill-down: the selected facet values feed straight back into where.
// Facet counts are computed on the FILTERED set, so they narrow as
// the user drills down.
// 1. First query - no filters. Facets show the full corpus:
// category: personal-finance (4), business-banking (3), market-insights (2)
// 2. User checks "business-banking" - re-query with:
{ query: "banking", categories: ["business-banking"], tags: null }
// 3. Facets now reflect only matching articles:
// category: business-banking (3)
// tags: cashflow (2), startups (2), tax (2)
// Passing null (not []) for an unused filter makes Graph skip it
// entirely - the API route converts empty selections to null.# Metadata fields facet too - no schema changes needed.
# _metadata.types buckets results by content type, useful for
# an "All / Articles / Pages" filter bar on a global search.
query SearchWithTypeFacet($query: String!) {
_Content(where: { _fulltext: { match: $query } }) {
total
facets {
_metadata {
types(limit: 10) { name count }
}
}
}
}
# → _Page (26), DynamicExperience (12), TraditionalPage (7),
# ArticlePage (5), FaqItemBlock (4), ...Autocomplete#
The autocomplete field returns matching values from the index rather than documents - ideal for type-ahead because it's cheap and needs no ranking. This page combines two sources in one query: tag values (suggesting search terms) and URL paths (suggesting pages to jump to directly). Like search, autocomplete responses must use cache: "no-store" - every keystroke is a unique query string.
# autocomplete also sits alongside items/total. Each eligible field
# takes (limit, value) and returns matching VALUES from the index -
# not documents. Two flavours used here:
#
# - ArticlePage tags: suggest query terms ("mo" → mortgage)
# - _Content _metadata.url.default: suggest pages by path segment
# ("sav" → /articles-demo/savings-tips-2025/)
query Autocomplete($value: String!) {
ArticlePage {
autocomplete {
tags(limit: 5, value: $value)
}
}
_Content {
autocomplete {
_metadata {
url { default(limit: 6, value: $value) }
}
}
}
}Prerequisite: queryable indexing#
searchable and queryable serve different engines: searchable feeds the full-text index (prose fields users search), queryable feeds the structured index (metadata you filter, sort, and facet on). A title should be searchable; a category enum should be queryable. Trying to facet on a searchable-only field returns a schema error, not empty buckets.
// Facets and autocomplete only work on fields Graph indexed for
// querying. In the content type definition:
export const ArticlePageType = contentType({
key: "ArticlePage",
baseType: "_page",
properties: {
// searchable → _fulltext finds it, but it CANNOT facet
title: { type: "string", indexingType: "searchable" },
// queryable → filter, sort, facet, autocomplete
category: { type: "string", indexingType: "queryable", enum: { values: [...] } },
tags: { type: "array", items: { type: "string" }, indexingType: "queryable" },
},
});
// Metadata fields (_metadata.types, url, locale, status) are always
// facetable - no configuration needed.Key Things to Know#
- →Facets ride along with results in one query. The
facetsselection returns value buckets with counts - no second request, no separate aggregation service. - →Facet counts are computed on the filtered set. Drill-down narrows both results and counts, which is exactly what users expect from e-commerce-style filtering.
- →Null filter variables are ignored by Graph. Write one query with all possible
infilters and passnullfor inactive ones - the API route converts empty selections to null. - →Facet ordering uses orderType, not orderBy.
orderType: COUNT | VALUEpicks the sort key;orderBy: ASC | DESCpicks the direction. - →Autocomplete returns values, not documents. Each field takes
(limit, value)and suggests indexed values - combine multiple fields (tags, URL paths) in one query for richer suggestions. - →Faceting requires queryable indexing.
indexingType: "queryable"on the field definition; metadata fields like_metadata.typesfacet out of the box. - →Facets add query cost. Each facet selection adds to Graph's complexity budget (visible in the response
extensions.costSummary) - request only the facets the UI renders.
Source files4 files
export const SEARCH_RELEVANCE_QUERY = /* GraphQL */ `
query SearchRelevance($query: String!, $locale: [Locales]) {
_Content(
locale: $locale
where: { _fulltext: { match: $query } }
orderBy: { _ranking: RELEVANCE }
limit: 10
tracking: { phrase: $query, source: "/search" }
) {
total
items {
_track
_score
_metadata {
displayName
url { default }
}
}
}
}
`;
export const SEARCH_FACETED_QUERY = /* GraphQL */ `
query SearchFaceted($query: String!, $categories: [String!], $tags: [String!], $locale: [Locales]) {
ArticlePage(
locale: $locale
where: {
_fulltext: { match: $query }
category: { in: $categories }
tags: { in: $tags }
}
orderBy: { _ranking: RELEVANCE }
limit: 10
tracking: { phrase: $query, source: "/demo/faceted-search" }
) {
total
items {
_score
category
tags
_metadata {
displayName
url { default }
}
}
facets {
category(orderType: COUNT, orderBy: DESC, limit: 10) { name count }
tags(orderType: COUNT, orderBy: DESC, limit: 12) { name count }
}
}
}
`;
export const AUTOCOMPLETE_QUERY = /* GraphQL */ `
query Autocomplete($value: String!) {
ArticlePage {
autocomplete {
tags(limit: 5, value: $value)
}
}
_Content {
autocomplete {
_metadata {
url { default(limit: 6, value: $value) }
}
}
}
}
`;
export const SEARCH_SEMANTIC_QUERY = /* GraphQL */ `
query SearchSemantic($query: String!, $weight: Float!, $locale: [Locales]) {
_Content(
locale: $locale
where: { _fulltext: { match: $query } }
orderBy: { _ranking: SEMANTIC, _semanticWeight: $weight }
limit: 10
tracking: { phrase: $query, source: "/search" }
) {
total
items {
_track
_score
_metadata {
displayName
url { default }
}
}
}
}
`;
import { type NextRequest, NextResponse } from "next/server";
import { graphqlFetch } from "@/lib/optimizely/client";
import {
SEARCH_FACETED_QUERY,
SEARCH_RELEVANCE_QUERY,
SEARCH_SEMANTIC_QUERY,
} from "@/lib/graphql/queries/SearchContent";
const SINGLE_KEY = process.env.OPTIMIZELY_GRAPH_SINGLE_KEY ?? "";
function listParam(value: string | null): string[] | null {
const parsed = value?.split(",").map((v) => v.trim()).filter(Boolean) ?? [];
return parsed.length > 0 ? parsed : null; // null = Graph ignores the filter
}
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const q = searchParams.get("q")?.trim() ?? "";
const mode = searchParams.get("mode") === "semantic" ? "semantic" : "relevance";
const weight = Math.min(1, Math.max(0, parseFloat(searchParams.get("weight") ?? "0.5")));
const locale = [searchParams.get("locale") ?? "en"];
if (!q || q.length < 2) {
return NextResponse.json({ total: 0, items: [] });
}
if (searchParams.get("facets") === "1") {
return facetedSearch(q, listParam(searchParams.get("category")), listParam(searchParams.get("tags")), locale);
}
try {
const result = await graphqlFetch<any>(
mode === "semantic" ? SEARCH_SEMANTIC_QUERY : SEARCH_RELEVANCE_QUERY,
mode === "semantic" ? { query: q, weight, locale } : { query: q, locale },
{ cache: "no-store" }
);
const raw = result.data?._Content ?? { total: 0, items: [] };
const items = (raw.items ?? [])
.filter((item: any) => item?._metadata?.displayName && item?._metadata?.url?.default)
.map((item: any) => ({
title: item._metadata.displayName as string,
url: item._metadata.url.default as string,
score: (item._score as number | null | undefined) ?? 0,
trackUrl: (() => {
const t = item._track as string | null | undefined;
return t && SINGLE_KEY ? `${t}&auth=${SINGLE_KEY}` : (t ?? null);
})(),
}));
return NextResponse.json({ total: raw.total ?? items.length, items });
} catch (error) {
console.error("[Search] Query failed:", error);
return NextResponse.json({ error: "Search failed" }, { status: 500 });
}
}
async function facetedSearch(q: string, categories: string[] | null, tags: string[] | null, locale: string[] = ["en"]) {
try {
const result = await graphqlFetch<any>(
SEARCH_FACETED_QUERY,
{ query: q, categories, tags, locale },
{ cache: "no-store" }
);
const raw = result.data?.ArticlePage ?? { total: 0, items: [], facets: {} };
const items = (raw.items ?? [])
.filter((item: any) => item?._metadata?.displayName && item?._metadata?.url?.default)
.map((item: any) => ({
title: item._metadata.displayName as string,
url: item._metadata.url.default as string,
score: (item._score as number | null | undefined) ?? 0,
category: (item.category as string | null | undefined) ?? null,
tags: (item.tags as string[] | null | undefined) ?? [],
}));
return NextResponse.json({
total: raw.total ?? items.length,
items,
facets: {
category: raw.facets?.category ?? [],
tags: raw.facets?.tags ?? [],
},
});
} catch (error) {
console.error("[Search] Faceted query failed:", error);
return NextResponse.json({ error: "Search failed" }, { status: 500 });
}
}
import { type NextRequest, NextResponse } from "next/server";
import { graphqlFetch } from "@/lib/optimizely/client";
import { AUTOCOMPLETE_QUERY } from "@/lib/graphql/queries/SearchContent";
export async function GET(request: NextRequest) {
const q = request.nextUrl.searchParams.get("q")?.trim() ?? "";
if (!q || q.length < 2) {
return NextResponse.json({ tags: [], paths: [] });
}
try {
const result = await graphqlFetch<any>(AUTOCOMPLETE_QUERY, { value: q }, { cache: "no-store" });
return NextResponse.json({
tags: result.data?.ArticlePage?.autocomplete?.tags ?? [],
paths: result.data?._Content?.autocomplete?._metadata?.url?.default ?? [],
});
} catch (error) {
console.error("[Autocomplete] Query failed:", error);
return NextResponse.json({ error: "Autocomplete failed" }, { status: 500 });
}
}
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
type FacetValue = { name: string; count: number };
type SearchResult = {
title: string;
url: string;
score: number;
category: string | null;
tags: string[];
};
type SearchResponse = {
total: number;
items: SearchResult[];
facets: { category: FacetValue[]; tags: FacetValue[] };
};
type Suggestions = { tags: string[]; paths: string[] };
const EMPTY_SUGGESTIONS: Suggestions = { tags: [], paths: [] };
export default function FacetedSearchDemo() {
const [query, setQuery] = useState("");
const [categories, setCategories] = useState<string[]>([]);
const [tags, setTags] = useState<string[]>([]);
const [response, setResponse] = useState<SearchResponse | null>(null);
const [suggestions, setSuggestions] = useState<Suggestions>(EMPTY_SUGGESTIONS);
const [showSuggestions, setShowSuggestions] = useState(false);
const [loading, setLoading] = useState(false);
const searchDebounce = useRef<ReturnType<typeof setTimeout> | null>(null);
const autocompleteDebounce = useRef<ReturnType<typeof setTimeout> | null>(null);
const search = useCallback((q: string, cats: string[], tgs: string[]) => {
if (searchDebounce.current) clearTimeout(searchDebounce.current);
if (!q || q.length < 2) {
setResponse(null);
return;
}
searchDebounce.current = setTimeout(async () => {
setLoading(true);
try {
const params = new URLSearchParams({ q, facets: "1" });
if (cats.length > 0) params.set("category", cats.join(","));
if (tgs.length > 0) params.set("tags", tgs.join(","));
const res = await fetch(`/api/search?${params}`);
setResponse(await res.json());
} catch {
setResponse(null);
} finally {
setLoading(false);
}
}, 300);
}, []);
const autocomplete = useCallback((q: string) => {
if (autocompleteDebounce.current) clearTimeout(autocompleteDebounce.current);
if (!q || q.length < 2) {
setSuggestions(EMPTY_SUGGESTIONS);
return;
}
autocompleteDebounce.current = setTimeout(async () => {
try {
const res = await fetch(`/api/search/autocomplete?q=${encodeURIComponent(q)}`);
const data = await res.json();
setSuggestions({ tags: data.tags ?? [], paths: data.paths ?? [] });
} catch {
setSuggestions(EMPTY_SUGGESTIONS);
}
}, 200);
}, []);
useEffect(() => {
search(query, categories, tags);
}, [query, categories, tags, search]);
function toggle(list: string[], value: string, set: (next: string[]) => void) {
set(list.includes(value) ? list.filter((v) => v !== value) : [...list, value]);
}
function applySuggestion(value: string) {
setShowSuggestions(false);
if (value.startsWith("/")) {
window.location.href = value;
return;
}
setQuery(value);
autocomplete("");
}
const hasSuggestions = suggestions.tags.length > 0 || suggestions.paths.length > 0;
return (
<div data-component="FacetedSearchDemo" className="bg-surface-lowest border border-ghost-border rounded-2xl p-6 space-y-4">
<div className="relative">
<input
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
autocomplete(e.target.value);
setShowSuggestions(true);
}}
onFocus={() => setShowSuggestions(true)}
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
placeholder="Search articles - try banking, mortgage, savings…"
className="w-full px-4 py-2 text-sm bg-surface border border-ghost-border rounded-xl text-on-surface placeholder:text-on-surface-variant/50 focus:outline-none focus:ring-2 focus:ring-brand/30"
/>
{showSuggestions && hasSuggestions && (
<div className="absolute z-20 mt-1 w-full bg-surface border border-ghost-border rounded-xl shadow-lift overflow-hidden">
{suggestions.tags.length > 0 && (
<div className="p-2">
<p className="text-[10px] uppercase tracking-widest font-semibold text-on-surface-variant px-2 pb-1">Tags</p>
{suggestions.tags.map((s) => (
<button
key={s}
type="button"
onMouseDown={() => applySuggestion(s)}
className="block w-full text-left px-2 py-1.5 text-sm text-on-surface rounded-lg hover:bg-surface-low"
>
{s}
</button>
))}
</div>
)}
{suggestions.paths.length > 0 && (
<div className="p-2 border-t border-ghost-border">
<p className="text-[10px] uppercase tracking-widest font-semibold text-on-surface-variant px-2 pb-1">Pages</p>
{suggestions.paths.map((s) => (
<button
key={s}
type="button"
onMouseDown={() => applySuggestion(s)}
className="block w-full text-left px-2 py-1.5 text-sm font-mono text-brand rounded-lg hover:bg-surface-low truncate"
>
{s}
</button>
))}
</div>
)}
</div>
)}
</div>
<div className="grid md:grid-cols-[220px_1fr] gap-6">
<div className="space-y-4">
<FacetGroup
label="Category"
values={response?.facets.category ?? []}
selected={categories}
onToggle={(v) => toggle(categories, v, setCategories)}
/>
<FacetGroup
label="Tags"
values={response?.facets.tags ?? []}
selected={tags}
onToggle={(v) => toggle(tags, v, setTags)}
/>
{(categories.length > 0 || tags.length > 0) && (
<button
type="button"
onClick={() => {
setCategories([]);
setTags([]);
}}
className="text-xs text-brand hover:underline"
>
Clear filters
</button>
)}
</div>
<div className="space-y-2 min-h-24">
{loading && <p className="text-xs text-on-surface-variant">Searching…</p>}
{!loading && response && (
<p className="text-xs text-on-surface-variant">
{response.total} result{response.total !== 1 ? "s" : ""} for{" "}
<strong className="text-on-surface">“{query}”</strong>
{categories.length + tags.length > 0 && (
<> · {categories.length + tags.length} filter{categories.length + tags.length !== 1 ? "s" : ""} active</>
)}
</p>
)}
{!loading && response && response.items.length > 0 && (
<ul className="space-y-2 divide-y divide-ghost-border">
{response.items.map((r) => (
<li key={r.url} className="pt-2 first:pt-0 text-sm">
<div className="flex items-center justify-between gap-4">
<a href={r.url} className="text-brand hover:underline truncate">{r.title}</a>
<span className="text-xs font-mono text-on-surface-variant shrink-0">{r.score.toFixed(1)}</span>
</div>
<p className="text-xs text-on-surface-variant mt-0.5">
{r.category && <span className="font-mono">{r.category}</span>}
{r.tags.length > 0 && <span className="font-mono"> · {r.tags.join(", ")}</span>}
</p>
</li>
))}
</ul>
)}
{!loading && query.length >= 2 && response && response.items.length === 0 && (
<p className="text-xs text-on-surface-variant italic">No results match the query and active filters.</p>
)}
{query.length < 2 && (
<p className="text-xs text-on-surface-variant italic">
Type at least two characters. Facet counts appear with the first results.
</p>
)}
</div>
</div>
<p className="text-xs text-on-surface-variant border-t border-ghost-border pt-3">
Calls <code className="bg-surface px-1 rounded font-mono">/api/search?facets=1</code> and{" "}
<code className="bg-surface px-1 rounded font-mono">/api/search/autocomplete</code> - facet
counts are computed by Graph on the filtered result set, so they narrow as you drill down.
</p>
</div>
);
}
function FacetGroup({
label,
values,
selected,
onToggle,
}: {
label: string;
values: FacetValue[];
selected: string[];
onToggle: (value: string) => void;
}) {
return (
<div data-component="FacetGroup">
<p className="text-[10px] uppercase tracking-widest font-semibold text-on-surface-variant mb-2">{label}</p>
{values.length === 0 ? (
<p className="text-xs text-on-surface-variant italic">No values yet.</p>
) : (
<ul className="space-y-1">
{values.map((v) => (
<li key={v.name}>
<label className="flex items-center gap-2 text-xs text-on-surface cursor-pointer">
<input
type="checkbox"
checked={selected.includes(v.name)}
onChange={() => onToggle(v.name)}
className="accent-brand"
/>
<span className="truncate">{v.name}</span>
<span className="ml-auto font-mono text-on-surface-variant">{v.count}</span>
</label>
</li>
))}
</ul>
)}
</div>
);
}