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.

Faceted search query used by this page
# 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.

The drill-down loop
// 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.
Faceting on metadata: _metadata.types
# 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.

Two autocomplete sources in one query
# 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.

indexingType decides what can facet
// 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 facets selection 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 in filters and pass null for inactive ones - the API route converts empty selections to null.
  • Facet ordering uses orderType, not orderBy. orderType: COUNT | VALUE picks the sort key; orderBy: ASC | DESC picks 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.types facet 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
src/lib/graphql/queries/SearchContent.ts
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 }
        }
      }
    }
  }
`;
src/app/api/search/route.ts
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 });
  }
}
src/app/api/search/autocomplete/route.ts
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 });
  }
}
src/app/demo/faceted-search/FacetedSearchDemo.tsx
"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">&ldquo;{query}&rdquo;</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>
  );
}