Developer Demo

Content Listing & Discovery

How to build article lists, filtered index pages, and faceted browsing interfaces using Graph queries, cursor pagination, where conditions, facet aggregations, and type-ahead autocomplete.

The list query - metadata only#

A list query fetches only the fields needed to render a card or row - title, date, URL, and a short summary. No composition, no block fragments, no content areas. This keeps the response small and the query predictable regardless of how much body copy each article has. Use the specific content type (ArticlePage) instead of _Content so Graph can expose that type's own custom fields like summary. SDK docs ↗

List query - metadata + summary only
# A list query fetches only the metadata fields needed for a list view -
# displayName, published date, and URL. No per-page content is fetched.
# This keeps list-page responses small regardless of how much body copy
# each article contains.

query GetArticles($limit: Int) {
  ArticlePage(
    limit: $limit
    orderBy: { _metadata: { published: DESC } }
    where: { _metadata: { url: { default: { exist: true } } } }
  ) {
    total
    cursor
    items {
      _metadata {
        displayName    # page title
        published      # ISO 8601 date
        url { default }
      }
      summary          # short description field on ArticlePage
      heroImage {
        _metadata { url { default } }
      }
    }
  }
}
_metadata - available on every type
// _metadata fields are available on every content type - no fragment needed.
// They are the minimum data required to render a list item without fetching full content.

type ListItem = {
  _metadata: {
    displayName: string;      // page title (always set)
    published:   string;      // ISO 8601 - use for sort order and "Published on" labels
    url: { default: string }; // the canonical public URL - href for the link
    __typename: string;       // "ArticlePage", "LandingPage", etc. - use for type badges
    locale: string;           // "en", "fr" - use when serving multi-locale lists
  };
  // plus whatever custom fields you include in your query (summary, heroImage, etc.)
};

Sorting and filtering#

orderBy takes any indexed field. Date-descending is the most common for blogs and news. Filtering with where narrows by metadata (date range, URL existence) or by content type fields (category, tag, author). All where conditions are ANDed together.

Filtering by date, type, and sorting
# Combine multiple where conditions to filter the list.
# All conditions are ANDed together.

query GetRecentArticles($since: DateTime!, $tag: String) {
  ArticlePage(
    limit: 12
    orderBy: { _metadata: { published: DESC } }
    where: {
      _metadata: {
        published:  { gte: $since }                # published after this date
        url:        { default: { exist: true } }   # must have a public URL
      }
      # Filter by a tag/category stored on the content type:
      # category: { eq: $tag }
    }
  ) {
    total
    cursor
    items { _metadata { displayName published url { default } } summary }
  }
}

# Sort by displayName alphabetically instead of date:
orderBy: { _metadata: { displayName: ASC } }

# _Page includes ALL page types (ArticlePage, LandingPage, BlogPage…):
query GetAllPages {
  _Page(
    limit: 20
    orderBy: { _metadata: { published: DESC } }
  ) { total cursor items { _metadata { __typename displayName url { default } } } }
}

Faceted filtering#

Facets let Graph count how many items match each field value within the current filtered result set. Request a facets block alongside your items block in the same query - no extra round trip needed. The counts automatically reflect any active where filters, so the sidebar always shows how many results each option would produce given the current selection. For dates, Graph can produce a histogram bucketed by day, week, month, or year.

GraphQL query with category and date facets
# Add a facets block alongside items to get per-field value counts.
# Facets are computed over the same result set the where clause produces,
# so counts always reflect the filtered view - not the full index.

query GetArticlesWithFacets($tag: [String], $since: DateTime) {
  ArticlePage(
    limit: 12
    orderBy: { _metadata: { published: DESC } }
    where: {
      category:  { in: $tag }                              # apply active filter
      _metadata: { published: { gte: $since } }
    }
  ) {
    total
    cursor
    items {
      _metadata { displayName published url { default } }
      summary
      category
    }
    facets {
      category {          # one bucket per unique value in "category"
        name              # the field value, e.g. "Mortgages"
        count             # how many items have this value given current filters
      }
      _metadata {
        published(        # date histogram - group by month
          unit: MONTH
          value: 10       # return up to 10 buckets
        ) {
          name            # human-readable label, e.g. "May 2025"
          count
          from            # ISO date for the bucket start (use as the $since value)
          to
        }
      }
    }
  }
}
Facet sidebar - Link-based, no useState
// Facet sidebar - each bucket is a link that sets (or clears) a search param.
// Using <Link> keeps this a server component with no useState.

function FacetSidebar({ facets, activeTag, activeSince }) {
  return (
    <nav>
      <h3>Category</h3>
      <ul>
        {facets.category.map(({ name, count }) => {
          const isActive = activeTag?.includes(name);
          const href = isActive
            ? buildUrl({ category: activeTag.filter(t => t !== name) })  // remove
            : buildUrl({ category: [...(activeTag ?? []), name] });       // add
          return (
            <li key={name}>
              <Link href={href} aria-current={isActive ? "true" : undefined}>
                {name} ({count})
              </Link>
            </li>
          );
        })}
      </ul>

      <h3>Published</h3>
      <ul>
        {facets._metadata.published.map(({ name, count, from }) => {
          const isActive = activeSince === from;
          const href = isActive
            ? buildUrl({ since: undefined })    // clear date filter
            : buildUrl({ since: from });         // set date filter
          return (
            <li key={from}>
              <Link href={href} aria-current={isActive ? "true" : undefined}>
                {name} ({count})
              </Link>
            </li>
          );
        })}
      </ul>
    </nav>
  );
}
Page component - facets + list in one query, filters via searchParams
// Typical pattern: facet selection lives in URL search params so
// the list page is shareable and server-rendered.
//
// src/app/articles/page.tsx

type SearchParams = { category?: string | string[]; since?: string };

export default async function ArticleListPage({
  searchParams,
}: {
  searchParams: Promise<SearchParams>;
}) {
  const sp = await searchParams;

  // Normalize multi-value param: ?category=Mortgages&category=Savings
  const tags = sp.category
    ? Array.isArray(sp.category) ? sp.category : [sp.category]
    : undefined;

  const since = sp.since ?? undefined;

  const res = await graphqlFetch(GET_ARTICLES_FACETS_QUERY, {
    tag: tags,
    since,
    limit: 12,
  });

  const { items, facets, total } = res.data.ArticlePage;

  return (
    <div className="grid grid-cols-[240px_1fr] gap-8">
      <FacetSidebar facets={facets} activeTag={tags} activeSince={since} />
      <ArticleGrid items={items} total={total} />
    </div>
  );
}

String facets (category, tag, author)

Each unique string value becomes a bucket. Counts reflect the current where clause - selecting a second tag shows only items that have both.

Date histogram facets

Pass unit (DAY / WEEK / MONTH / YEAR) and value (max buckets). The from field on each bucket is the ISO date you pass back as $since to activate that filter.

Search-driven facets & autocomplete#

The browse example above filters a list. Pair the same facets block with a _fulltext search and add type-ahead, and you get a full discovery interface - all computed by Graph in the same query as the results, no separate aggregation or suggestion service. Type banking or mortgage and pause: autocomplete suggests tags and page paths as you type, then the checkboxes drill into the results with live counts. The Fuzzy matching toggle sends fuzzy: true so a typo like morgage still resolves.

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 autocomplete field returns matching values from the index rather than documents - cheap and needs no ranking, ideal for type-ahead. This demo combines two sources in one query: tag values (suggesting search terms) and URL paths (suggesting pages to jump to). Like search, autocomplete responses use cache: "no-store" - every keystroke is a unique query.

Two autocomplete sources in one query
# autocomplete sits alongside items/total. Each eligible field
# takes (limit, value) and returns matching VALUES from the index -
# not documents. Two flavours used by the demo above:
#
#   - 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) }
      }
    }
  }
}
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!, $fuzzy: Boolean) {
  _Content(where: { _fulltext: { match: $query, fuzzy: $fuzzy } }) {
    total
    facets {
      _metadata {
        types(limit: 10) { name count }
      }
    }
  }
}

# → _Page (26), DynamicExperience (12), TraditionalPage (7),
#   ArticlePage (5), FaqItemBlock (4), ...
Prerequisite: queryable indexing decides what can facet
// Facets and autocomplete only work on fields Graph indexed for
// querying. searchable and queryable serve different engines:
// searchable feeds the full-text index (prose users search),
// queryable feeds the structured index (metadata you filter/sort/facet).

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. Faceting a searchable-only field
// returns a schema error, not empty buckets.

Cursor pagination#

Graph returns a cursor string alongside every result set. Pass it back on the next request to get the next page. Unlike offset-based pagination, cursors are stable even if new items are published between requests - the pointer into the result set doesn't shift. The live demo above uses cursor pagination - the Next page button passes the cursor back in the URL.

Consuming cursor pagination in server components
# Cursor pagination - Graph returns an opaque cursor string.
# Pass it back on the next request to get the next page.
# Cursors remain valid even if new content is published between requests.

const PAGE_SIZE = 12;

// Page 1 - no cursor
const res1 = await graphqlFetch(GET_ARTICLES_QUERY, { limit: PAGE_SIZE });
const { items, cursor, total } = res1.data.ArticlePage;
// cursor = "eyJza2lwIjoxMn0="  (opaque - never parse it)

// Page 2 - add cursor to the query variable
const GET_ARTICLES_WITH_CURSOR = `
  query GetArticles($limit: Int, $cursor: String) {
    ArticlePage(limit: $limit, cursor: $cursor, ...) {
      total
      cursor     # ← cursor for the NEXT page after this one
      items { ... }
    }
  }
`;

const res2 = await graphqlFetch(GET_ARTICLES_WITH_CURSOR, {
  limit: PAGE_SIZE,
  cursor: cursor,   // ← cursor from page 1 response
});
Why cursor is better than offset
# Why NOT offset pagination with Graph

# ❌ Offset (skip) - fragile under concurrent writes
# If 3 articles are published between page 1 and page 2 requests,
# skip: 12 will repeat 3 items already shown (or skip 3 unseen items).
query GetArticlesOffset($skip: Int) {
  ArticlePage(limit: 12) {  # Graph doesn't expose a native skip param
    items { ... }            # you'd have to slice results in app code
  }
}

# ✅ Cursor - stable pointer into the result set
# New publishes don't affect the cursor position - next page is always
# relative to the last item you saw, not an absolute row number.
query GetArticlesCursor($cursor: String) {
  ArticlePage(limit: 12, cursor: $cursor) {
    cursor   # the next page cursor - null when there are no more pages
    items { ... }
  }
}

ISG vs. force-dynamic for list pages#

List pages can be statically generated (ISR), rendered on demand (force-dynamic), or a hybrid: pre-render page 1 at build time and render subsequent pages on first request. ISR is ideal when the list changes infrequently - first page is pre-built, all visitors share the cache. force-dynamic is right when editors publish frequently and showing stale results for even 60s is unacceptable (e.g. a breaking-news feed).

generateStaticParams + ISR for paginated list pages
// src/app/articles/page/[page]/page.tsx
//
// Option A - ISG: pre-render page 1; render subsequent pages on demand.
// Pages 2+ are generated on first request and cached as ISR.

export async function generateStaticParams() {
  return [{ page: "1" }];   // only page 1 pre-rendered at build time
}

export const revalidate = 60;   // ISR - stale-while-revalidate

export default async function ArticleListPage({ params }) {
  const pageNum = parseInt(params.page, 10) || 1;
  const cursor  = await getCursorForPage(pageNum, PAGE_SIZE);

  const res = await graphqlFetch(GET_ARTICLES_QUERY, { cursor, limit: PAGE_SIZE });
  return <ArticleList items={res.data.ArticlePage.items} />;
}

// Option B - force-dynamic: always serve fresh results.
// Use this when editors publish frequently and staleness matters.
export const dynamic = "force-dynamic";

generateStaticParams + revalidate

Page 1 pre-rendered at build. Pages 2+ rendered on first request, then ISR-cached. Lowest TTFB for popular pages.

Best for: Blog, documentation, product catalogue

force-dynamic

List re-fetched on every request. Always shows the latest publish. Higher TTFB.

Best for: News feeds, real-time dashboards

Server action / client fetch

Page shell is static; list data is loaded by the client after hydration. Progressive enhancement.

Best for: Search results, filtered lists with user-driven criteria

Key Things to Know#

  • List queries should fetch only metadata + summary fields. Avoid fetching composition, content areas, or block fragments - that data is for detail pages, not list cards.
  • Use the specific content type, not _Content, when you need custom fields (summary, category, heroImage). _Content only exposes base _metadata fields.
  • Cursor pagination is stable under concurrent publishes. Offset pagination re-numbers rows when new items are inserted - users on page 2 can see duplicates or miss items.
  • cursor: null means there are no more pages. Always check before rendering a "Load more" or "Next page" control.
  • Pre-render page 1 with generateStaticParams; leave pages 2+ on-demand. This gives the most-visited page zero TTFB without pre-building every paginated offset at deploy time.
  • _metadata.published is the canonical sort key for recency. It reflects the last publish date, not the creation date - republishing updates it, which is the right behaviour for editors who update old articles.
  • Facet counts are scoped to the active where clause. If a user has filtered by category, the date histogram counts only articles in that category - not the whole index. Request facets in the same query as items so you pay one round trip.
  • 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.
  • Facets and autocomplete require queryable indexing. indexingType: "queryable" on the field definition; metadata fields like _metadata.types facet out of the box. A searchable-only field returns a schema error, not empty buckets.
  • fuzzy: true on the search filter tolerates typos. Faceted search combines _fulltext: { match, fuzzy } with the where facet filters in one query - fuzzy widens the candidate set before the facet counts are computed.
Source files5 files
src/lib/graphql/queries/GetArticles.ts
import { graphqlFetch, CACHE_TTL } from "@/lib/optimizely/client";

export interface ArticleListItem {
  title?: string | null;
  summary?: string | null;
  category?: string | null;
  _metadata?: {
    published?: string | null;
    url?: { default?: string | null } | null;
  } | null;
}

export interface ArticleFacetBucket {
  name: string;
  count: number;
}

export interface ArticleListResult {
  items: ArticleListItem[];
  total: number;
  nextCursor: string | null;
  facets: { category: ArticleFacetBucket[] };
  fromCms: boolean;
}

const GET_ARTICLES_QUERY = /* GraphQL */ `
  query GetArticles($limit: Int, $cursor: String) {
    ArticlePage(
      limit: $limit
      cursor: $cursor
      orderBy: { _metadata: { published: DESC } }
      where: { _metadata: { url: { default: { exist: true } } } }
    ) {
      total
      cursor
      items {
        title
        summary
        category
        _metadata { published url { default } }
      }
      facets {
        category { name count }
      }
    }
  }
`;

const GET_ARTICLES_FILTERED_QUERY = /* GraphQL */ `
  query GetArticlesFiltered($limit: Int, $cursor: String, $category: [String]!) {
    ArticlePage(
      limit: $limit
      cursor: $cursor
      orderBy: { _metadata: { published: DESC } }
      where: {
        _metadata: { url: { default: { exist: true } } }
        category: { in: $category }
      }
    ) {
      total
      cursor
      items {
        title
        summary
        category
        _metadata { published url { default } }
      }
      facets {
        category { name count }
      }
    }
  }
`;

interface GraphResponse {
  ArticlePage?: {
    total?: number | null;
    cursor?: string | null;
    items?: Array<ArticleListItem> | null;
    facets?: {
      category?: Array<{ name?: string | null; count?: number | null }> | null;
    } | null;
  } | null;
}

const EMPTY: ArticleListResult = {
  items: [],
  total: 0,
  nextCursor: null,
  facets: { category: [] },
  fromCms: false,
};

function mapResponse(data: GraphResponse | null): ArticleListResult {
  const page = data?.ArticlePage;
  if (!page) return EMPTY;
  return {
    items: (page.items ?? []).filter(Boolean) as ArticleListItem[],
    total: page.total ?? 0,
    nextCursor: page.cursor ?? null,
    facets: {
      category: (page.facets?.category ?? [])
        .filter((b): b is { name: string; count: number } => !!b.name && b.count != null)
        .map(b => ({ name: b.name, count: b.count })),
    },
    fromCms: (page.items?.length ?? 0) > 0,
  };
}

export async function getArticles(options?: {
  limit?: number;
  cursor?: string | null;
  category?: string[] | null;
}): Promise<ArticleListResult> {
  const { limit = 6, cursor, category } = options ?? {};
  try {
    if (category?.length) {
      const res = await graphqlFetch<GraphResponse>(
        GET_ARTICLES_FILTERED_QUERY,
        { limit, cursor: cursor ?? undefined, category },
        { next: { revalidate: CACHE_TTL, tags: ["page"] } }
      );
      return mapResponse(res.data);
    }
    const res = await graphqlFetch<GraphResponse>(
      GET_ARTICLES_QUERY,
      { limit, cursor: cursor ?? undefined },
      { next: { revalidate: CACHE_TTL, tags: ["page"] } }
    );
    return mapResponse(res.data);
  } catch {
    return EMPTY;
  }
}
src/lib/graphql/queries/SearchContent.ts
export const SEARCH_RELEVANCE_QUERY = /* GraphQL */ `
  query SearchRelevance($query: String!, $locale: [Locales], $fuzzy: Boolean) {
    SEO(
      locale: $locale
      where: { _fulltext: { match: $query, synonyms: [ONE], fuzzy: $fuzzy } }
      orderBy: { _ranking: RELEVANCE }
      limit: 10
      pinned: { phrase: $query }
      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], $fuzzy: Boolean) {
    ArticlePage(
      locale: $locale
      where: {
        _fulltext: { match: $query, fuzzy: $fuzzy }
        category: { in: $categories }
        tags: { in: $tags }
      }
      orderBy: { _ranking: RELEVANCE }
      limit: 10
      tracking: { phrase: $query, source: "/demo/listing" }
    ) {
      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)
      }
    }
    SEO {
      autocomplete {
        _metadata {
          url { default(limit: 6, value: $value) }
        }
      }
    }
  }
`;

export const SEARCH_SEMANTIC_QUERY = /* GraphQL */ `
  query SearchSemantic($query: String!, $weight: Float!, $locale: [Locales], $fuzzy: Boolean) {
    SEO(
      locale: $locale
      where: { _fulltext: { match: $query, synonyms: [ONE], fuzzy: $fuzzy } }
      orderBy: { _ranking: SEMANTIC, _semanticWeight: $weight }
      limit: 10
      pinned: { phrase: $query }
      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 ?? "";

// Pinned results carry a _score boosted by 2^32-1 (~4.29e9); organic scores stay
// in the hundreds/thousands. 1e9 is an unambiguous cutoff for flagging a pinned hit.
const PINNED_SCORE_THRESHOLD = 1_000_000_000;

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"];
  // Fuzzy (typo-tolerant) matching is on by default; only an explicit fuzzy=0 disables it.
  const fuzzy  = searchParams.get("fuzzy") !== "0";

  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, fuzzy);
  }

  try {
    const result = await graphqlFetch<any>(
      mode === "semantic" ? SEARCH_SEMANTIC_QUERY : SEARCH_RELEVANCE_QUERY,
      mode === "semantic" ? { query: q, weight, locale, fuzzy } : { query: q, locale, fuzzy },
      { cache: "no-store" }
    );

    const raw = result.data?.SEO ?? { total: 0, items: [] };

    const items = (raw.items ?? [])
      .filter((item: any) => item?._metadata?.displayName && item?._metadata?.url?.default)
      .map((item: any) => {
        const score = (item._score as number | null | undefined) ?? 0;
        return {
          title:    item._metadata.displayName as string,
          url:      item._metadata.url.default as string,
          score,
          pinned:   score >= PINNED_SCORE_THRESHOLD,
          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"], fuzzy = true) {
  try {
    const result = await graphqlFetch<any>(
      SEARCH_FACETED_QUERY,
      { query: q, categories, tags, locale, fuzzy },
      { 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?.SEO?.autocomplete?._metadata?.url?.default ?? [],
    });
  } catch (error) {
    console.error("[Autocomplete] Query failed:", error);
    return NextResponse.json({ error: "Autocomplete failed" }, { status: 500 });
  }
}
src/app/demo/listing/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 [fuzzy, setFuzzy] = useState(true);
  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[], fz: boolean) => {
    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(","));
        if (!fz) params.set("fuzzy", "0");
        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, fuzzy);
  }, [query, categories, tags, fuzzy, 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>

      <label className="flex items-center gap-2 text-xs text-on-surface-variant cursor-pointer">
        <input
          type="checkbox"
          checked={fuzzy}
          onChange={(e) => setFuzzy(e.target.checked)}
          className="accent-brand"
        />
        Fuzzy matching (typo tolerance) - sends{" "}
        <code className="bg-surface px-1 rounded font-mono">fuzzy: true</code>
      </label>

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