Developer Demo
Content Listing & Discovery
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 ↗
# 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 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.)
};Saving for your first home: a 5-step plan that actually works
Buying your first home feels impossible until it isn't. Here's the saving-and-application sequence we walk first-time buyers through every week.
9 Sept 2026
Market InsightsMortgage rates in 2026: what's actually moving them
Headline rates make great news. They make terrible decision-making tools. Here's what to watch instead.
9 Sept 2026
Business BankingBusiness banking essentials for founders in year one
Five banking decisions that quietly determine whether your first year is calm or chaotic. Most founders only realise they got them wrong at month nine.
9 Sept 2026
Personal FinanceSaving for your first home: a 5-step plan that actually works
Buying your first home feels impossible until it isn't. Here's the saving-and-application sequence we walk first-time buyers through every week.
9 Sept 2026
Market InsightsMortgage rates in 2026: what's actually moving them
Headline rates make great news. They make terrible decision-making tools. Here's what to watch instead.
9 Sept 2026
Business BankingBusiness banking essentials for founders in year one
Five banking decisions that quietly determine whether your first year is calm or chaotic. Most founders only realise they got them wrong at month nine.
9 Sept 2026
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.
# 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.
# 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 - 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>
);
}// 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.
Mortgage rates in 2026: what's actually moving them
Headline rates make great news. They make terrible decision-making tools. Here's what to watch instead.
9 Sept 2026
Market InsightsMortgage rates in 2026: what's actually moving them
Headline rates make great news. They make terrible decision-making tools. Here's what to watch instead.
9 Sept 2026
Showing 2 of 2
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.
# 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) }
}
}
}
}# 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), ...// 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.
# 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 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).
// 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 | 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. - →Facets and autocomplete require queryable indexing.
indexingType: "queryable"on the field definition; metadata fields like_metadata.typesfacet out of the box. A searchable-only field returns a schema error, not empty buckets. - →
fuzzy: trueon the search filter tolerates typos. Faceted search combines_fulltext: { match, fuzzy }with thewherefacet filters in one query - fuzzy widens the candidate set before the facet counts are computed.
Source files5 files
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;
}
}
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 }
}
}
}
}
`;
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 });
}
}
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 });
}
}
"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">“{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>
);
}