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 ↗

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.

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.