A listing block fetches content from the server (e.g. latest news) and renders each result as a separate block, repeating each block once per result entry. This means a listing can be moved between containers and reuse normal blocks for what it repeats.
expandListingBlocks(layout, options) is a helper in hydra.js that handles fetching, paging, and mapping results to block objects. It walks a layout, fetches results for each listing-type block, and returns { items, paging } where items is an array of block objects with @uid and @type.
You tell it which block types need fetching via a fetchItems map — keys are block types, values are fetcher functions. This means you can have different kinds of listings (Plone queries, RSS feeds, etc.) each with their own fetcher:
A grid can have a mix of listing and static blocks sharing a single paging. The staticBlocks helper wraps non-listing blocks so they participate in the shared page window. The listings use Suspense so they load client-side:
{ blockType: async (block, { start, size }) => { items, total } }. Keys declare which block types to expand; values are fetcher functions. Use ploneFetchItems() for Plone backends.{ start, size } (not mutated). Computed values are returned in the response.paging.seen from one call to the next for grids.'itemType')'summary')ploneFetchItems({ apiUrl, contextPath, extraCriteria }) creates a fetcher function for Plone's @querystring-search endpoint, suitable as a value in the fetchItems map.
Option | Default | Description |
|---|---|---|
| — | Plone site URL (e.g. |
|
| Path for relative queries |
|
| Additional query params — |
A listing with no querystring defaults to showing the current folder's contents in folder order.
ploneFetchItems also normalizes Plone's image data — packaging image_field + image_scales into a self-contained image object with @id duplicated inside (needed for URL resolution):
This self-contained object has everything needed to resolve image URLs with scale support — see the Nuxt example's composables/imageProps.js for one approach.
For non-Plone backends (RSS feeds, external APIs, etc.), write your own fetcher: async (block, { start, size }) => ({ items, total }). start is the zero-based offset, size is the number of items to return (or 0 for total-only), and total in the return value is the full count, not just this page.
fieldMapping on a listing block controls which fields appear on expanded items — only mapped fields are included. Default: { @id → href, title → title, description → description, image → image }. Values can be a string (rename) or { field, type } for conversions:
Built-in item types and the fields they expose:
Type | Fields |
|---|---|
|
|
|
|
|
|
Use variation on the listing block to control what @type expanded items get. Listings reuse the same inheritSchemaFrom recipe as container blocks (see Container Blocks › Synchronised Block Types) but differ in one structural way: there's no blocks field to declare itemTypeField on, since listing children are virtual (produced from query results at render time, not authored as page data). Instead, declare the typeField directly on the inheritSchemaFrom recipe:
filterConvertibleFrom: '@default' restricts the dropdown to types that have a fieldMappings['@default'] entry — i.e. types that can be populated from the canonical content fields (@id, title, description, image) that listing queries return. Each item type's fieldMappings['@default'] (on its own block config) defines how those source fields land on its schema; that static mapping is enough to render listings. Adding mappingField to the enhancer exposes the FieldMappingWidget so the editor can override the mapping per listing instance.
The widget saves its output as fieldMapping (singular) on the block data. expandListingBlocks reads that at render time to translate each query result into an item block.
A container (e.g. gridBlock) can mix manual children AND a listing as children. Add 'listing' to the blocks field's allowedBlocks, and the parent's typeField propagates everywhere:
filterConvertibleFrom: '@default' keeps 'listing' out of the dropdown (it's a structural container, not an item type, so it has no fieldMappings['@default']) but it stays in allowedBlocks so a listing block can still exist as a structural child. The editor sees "Teaser / Image / Summary" in the picker; the listing is a structural choice they don't have to think about.
When the editor changes gridBlock.variation to e.g. 'summary':
@type converted via the destination type's fieldMappings — teaser becomes summary.@type: 'listing' but its own variation field is set to 'summary', so the listing now renders summary items.The sync walks recursively — if the listing held nested containers with their own typeFields, those would update too. Net effect: ONE picker on the parent controls the rendered type for every descendant, regardless of whether descendants are authored manually or expanded from a query.
If your frontend embeds state in the URL path (like pagination), you need to tell hydra.js how to transform the frontend path to the API/admin path. Otherwise, the admin will try to navigate to URLs that don't exist in the CMS.
The pathToApiPath function is called whenever hydra.js sends a PATH_CHANGE message to the admin, allowing your frontend to strip or transform URL segments that are frontend-specific (like pagination, filters, or other client-side state).
Both expandListingBlocks and staticBlocks return { items, paging }. You pass { start, size } as input (not mutated) and get back computed paging values:
{ start, page } where page is 1-basedseen option for position tracking in gridsNeither function mutates the input paging object — calling again with the same { start, size } is safe.
When multiple listings share a pager (e.g. a grid with several listings), expandListingBlocks walks them sequentially. Each fetch returns { items, total }, so the total is learned from the response and used to compute where the next listing starts. One request per listing. Listings outside the page window are fetched with size: 0 (total only, no items).
When mixing listings with static blocks in a shared pager, use staticBlocks(ids, { blocks, paging, seen }) for the non-listing blocks — it tracks their position in the paging window. Chain the returned paging.seen to the next call so each block knows its offset (see the React example above).
Expanded listing items share the listing block's @uid. Selecting any expanded item selects the parent listing block.