Developer Demo
Content Listing & Discovery
How to build article lists, filtered index pages, and faceted browsing interfaces using Graph queries, cursor pagination, where conditions, and facet aggregations.
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.)
};5 Savings Tips for 2025
From emergency funds to high-interest accounts, five habits that will make your money work harder this year.
20 Jul 2026
Business BankingBusiness Banking Basics
How to choose the right current account, manage cash flow, and integrate banking with your accounting tools.
20 Jul 2026
Personal FinanceGuide to ISAs
Everything you need to know about Individual Savings Accounts — from annual limits to tax-free growth.
20 Jul 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.
20 Jul 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.
20 Jul 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.
20 Jul 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.
Business Banking Basics
How to choose the right current account, manage cash flow, and integrate banking with your accounting tools.
20 Jul 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.
20 Jul 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.
4 Jun 2026
Showing 3 of 3
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.