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.
Querying a stream
Section titled “Querying a stream”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:
/** * 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.
Declared filters
Section titled “Declared filters”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-09Repeating 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.
Full-text search
Section titled “Full-text search”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.
Facets for filter chips
Section titled “Facets for filter chips”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 } ]}valueis what to send back as the filter.labelis a readable name forcategoryandreferencevalues, andnullforstringanddatevalues, which read as they are.pathis 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
fullTexthas facets; asking for the full-text filter, or an undeclared one, answers400 unknown_filter_field. Facets takelocalefor their labels, and noctx.
// 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), }))}Paging
Section titled “Paging”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.
Items shaped by an Adapter
Section titled “Items shaped by an Adapter”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.