Skip to content

Streams, filters and search

A stream is a named listing an editor defines in Content under Configure → Streams. The stream holds the decisions a listing page would otherwise hard-code: which contracts it draws from, the order, anything excluded, how deeply each item’s references are expanded, an optional Adapter that reshapes each item, and which filters callers may use. Your site asks for the stream by its external id and supplies only paging and filter values, so an editor can change what a listing shows without a deploy.

A stream only ever returns published content. Changes to its definition, or to its Adapter’s rules, apply to the next request.

GET /content/delivery/v1/streams/{externalId} returns one page:

{
"stream": { "id": "", "externalId": "site-search" },
"items": [
{
"provider": "core",
"key": "",
"contract": { "id": "", "externalId": "card", "version": 3 },
"content": { "heading": "Brewing at home", "excerpt": "" },
"paths": [{ "siteRootNodeId": "", "path": "/blog/brewing-at-home" }]
}
],
"nextCursor": ""
}

Every item carries content and paths; there is no include or resolve to set, because the stream decides. The parameters you can send are limit (1 to 100, 20 by default), cursor, locale, ctx (see Personalization), include=referencePaths (see Navigation, links and sitemaps), and the stream’s declared filters. In the SDK, it is queryStream on the low-level client, which a high-level client exposes as content.delivery:

stream.tsx
/**
* Streams: a global site-search page over an authored stream: the stream's definition owns the source set,
* order, and projection; this consumer supplies only the declared `q` filter and pages by cursor.
*/
import { createDeliveryClient, type StreamFacetResult, type StreamPage } from '@ebitex/content-sdk'
const delivery = createDeliveryClient({ apiKey: 'pk_…browser-safe…' })
export async function searchSite(query: string, cursor?: string): Promise<StreamPage> {
return delivery.queryStream('site-search', {
filters: { q: query },
limit: 20,
cursor,
})
}
export async function collectAllResults(query: string): Promise<StreamPage['items']> {
const items: StreamPage['items'] = []
let cursor: string | undefined
do {
const page = await searchSite(query, cursor)
items.push(...page.items)
cursor = page.nextCursor ?? undefined
} while (cursor)
return items
}
/**
* Facets: `{value, label, count}` chips for a declared filter's own
* dimension, computed against every OTHER currently active filter — a facet never narrows itself,
* so the target field's own value (if present in `activeFilters`) is silently excluded server-side.
*/
export async function blogTopicChips(activeFilters: Record<string, string | readonly string[]>): Promise<StreamFacetResult> {
return delivery.getStreamFacet('blog-posts', 'topic', { filters: activeFilters })
}

An unknown stream answers 404 unknown_stream.

A stream accepts only the filters its editor declared, each under a key they chose. Send them as filter.<key>=:

GET /content/delivery/v1/streams/articles?filter.topic=topics/coffee&filter.topic=topics/tea&filter.month=2026-09

Repeating a key matches any of its values, and different keys must all match: the request above is “coffee or tea, from September 2026”. A key the stream doesn’t declare answers 400 validation_failed.

Kind Value to send Matches
string Text Items whose field equals the text exactly
category A category path, such as topics/coffee, or a category id Items classified with that category or any category beneath it
reference A component’s id Items whose field references that component
date A month, YYYY-MM, in UTC Items whose date field falls in that month
fullText One search string Items whose text matches the search

string, reference and date filters read one top-level field. A field that editors can localize or personalize never matches these filters.

A category filter can be scoped to one category group, so that a contract with two category fields (a roast and a process, say) keeps them apart. Sending a category from another group answers 400 category_not_in_group, and an unknown category answers 400 unknown_category.

A stream has at most one fullText filter, and it takes exactly one value. It searches every piece of text in each published item, whatever field it is in:

You send Matches items containing
espresso grinder both words
"cold brew" the exact phrase
espresso OR filter either word
espresso -decaf espresso, but not decaf

Words match whole and regardless of case. There is no stemming or prefix matching, so brew does not match brewing.

GET /content/delivery/v1/streams/{externalId}/facets?field=<key> lists the values one declared filter can take, with how many items match each:

{
"field": "topic",
"values": [
{ "value": "topics/coffee", "path": "topics/coffee", "label": "Coffee", "count": 12 },
{ "value": "topics/tea", "path": "topics/tea", "label": "Tea", "count": 4 }
]
}
  • value is what to send back as the filter.
  • label is a readable name for category and reference values, and null for string and date values, which read as they are. path is present for categories.
  • Counts respect every other active filter but not the facet’s own, so a facet never narrows itself. Send the facet exactly the filters you send the stream, its own included, and choosing one topic still shows how many items every other topic has.
  • Every kind except fullText has facets; asking for the full-text filter, or an undeclared one, answers 400 unknown_filter_field. Facets take locale for their labels, and no ctx.
stream-filters.ts
// A filtered listing over a stream: one page of items, and a set of filter chips for each facetable
// filter. The `articles` stream here declares `q` (full text), `topic` (category), `author`
// (reference) and `month` (date), and maps each item through an Adapter to a card.
import { createDeliveryClient, pathForSite, type StreamFacetResult, type StreamPage } from '@ebitex/content-sdk'
const delivery = createDeliveryClient({ apiKey: 'pk_…browser-safe…' })
const STREAM = 'articles'
const FACETS = ['topic', 'author', 'month']
/** What the visitor has chosen, keyed by the stream's filter keys. */
export interface Choices {
search: string
selected: Record<string, readonly string[]>
}
function toFilters(choices: Choices): Record<string, string | readonly string[]> {
// A key with nothing selected sends nothing. Several values for one key match any of them, and
// different keys must all match.
const filters: Record<string, string | readonly string[]> = { ...choices.selected }
// The full-text filter takes one value: words, "quoted phrases", OR, and -word.
const search = choices.search.trim()
if (search) filters.q = search
return filters
}
export async function loadListing(choices: Choices, cursor?: string): Promise<{ page: StreamPage; facets: StreamFacetResult[] }> {
const filters = toFilters(choices)
// Every request carries the same filters, including each facet's own. The API leaves a facet's
// own filter out of its counts, so choosing one topic still shows how many items the others have.
const [page, facets] = await Promise.all([
delivery.queryStream(STREAM, { filters, limit: 12, cursor }),
Promise.all(FACETS.map((field) => delivery.getStreamFacet(STREAM, field, { filters }))),
])
return { page, facets }
}
// `value` is what to send back as the filter. Category and reference values carry a readable
// `label`. A string value is readable as it is, and a date value is a month such as `2026-09`.
export function chips(facet: StreamFacetResult) {
return facet.values.map((entry) => ({ value: entry.value, label: entry.label ?? entry.value, count: entry.count }))
}
// With an Adapter on the stream, `content` is the Adapter's output rather than the item itself, and
// `item.contract` names the output contract.
type Card = { heading: string; excerpt: string | null }
export function cards(page: StreamPage) {
return page.items.map((item) => ({
key: item.key,
card: item.content as Card | undefined,
// A stream item always carries its published addresses.
href: pathForSite(item.paths),
}))
}

nextCursor is opaque: pass it back as cursor exactly as you received it, with the same filters. null means there are no more items. If an editor changes the stream’s order while you page, an older cursor answers 400 invalid_cursor; start again from the first page. collectAllResults in the first example shows the loop.

A stream can pass every item through an Adapter, a mapping an editor defines from one contract to another. Then content is the Adapter’s output, not the item’s own document, and contract names the output contract. That is how one stream can list blog posts, product pages and help articles as the same card shape, and how a listing can stay small when the items themselves are large.

Write your list renderer against the output contract. If a mapping produces warnings for an item, the item carries them in adapterWarnings. Each item’s paths still points at the item’s own page, and listing items that aren’t bound to any page carry an empty array. See Typed content for generating types for the output contract.