Implementing Carousels
- Headless
- React Components
- React Hooks
- Web Components
This section covers the request options and response fields specific to product carousel creatives. For how to build and send a bid request, see Building Bid Requests.
Requesting a carousel
Carousel requests use .andProductCards() instead of .andBanner() or .andVideo(). Pass ads.defaultNative() to build the native request format used for product cards. A request typically covers more than the carousel slot alone — here, a carousel alongside a banner:
/* const ads = await LoadSurfAdsCoreHeadless({ ... }) — see Initialization */
const request = ads.startRequest('your-zone-id')
.withImp('carousel-zone-id')
.andProductCards(ads.defaultNative())
.withImp('banner-zone-id')
.andBanner(4, 1)
.build();
const bidsRes = await ads.requestBids(request);
if (!bidsRes.ok) {
console.error(bidsRes.error);
return;
}
Note: Surfside implements two separate OpenRTB specifications: OpenRTB 2.6 (the main bid request, built with
startRequest) and OpenRTB Native 1.2 (the native ad payload, built withdefaultNative). Because they are distinct specs, they have distinct builders.andProductCards()accepts a native request built bydefaultNative()and embeds it inside the outer OpenRTB 2.6 request.
You can also pass categories and keywords for targeting before calling .withImp():
const request = ads.startRequest('your-zone-id')
.withSurfCategory(['edibles'])
.withKeywords(['organic', 'sale'])
.withImp('carousel-zone-id')
.andProductCards(ads.defaultNative())
.withImp('banner-zone-id')
.andBanner(4, 1)
.build();
Building carousel responses
buildAllSimpleCarousels takes the full bid response and returns a Result containing an array of carousels, pulling out just the carousel creatives even if the request also asked for other creative types. Each carousel has a products array of resolved product cards, where each item includes the product data, clickthrough URL, and whether it is a sponsored placement.
const carouselsRes = await ads.buildAllSimpleCarousels(bidsRes.value);
if (!carouselsRes.ok) {
console.error(carouselsRes.error);
return;
}
If the request mixed creative types on the same impression, use buildAllResponsesCore instead (see Building Bid Requests) and switch on creative.type === 'simpleCarousel'.
Writing the output
Each product card has a product object with fields like name, image, and price, a clickthrough URL, and a sponsored flag. How you serialize and deliver this is up to your application — for example, writing it as JSON for a frontend to consume:
for (const carousel of carouselsRes.value) {
for (const item of carousel.products) {
res.write(JSON.stringify({
name: item.product.name,
image: item.product.image,
clickthrough: item.clickthrough,
sponsored: item.sponsored
}));
}
}
Tracking
Unlike banners and videos, the OpenRTB Native 1.2 spec supports multiple named tracking events per creative.
There are two types of tracker:
- Pixel (
type: 'pixel') — a URL to fire as a 1×1<img>tag or afetchcall. - Script (
type: 'script') — a URL to load as a<script src="...">tag. Script trackers are optional; you are not required to fire them.
Win and impression
win and impression are tracked per carousel, not per product card — fire them once for the carousel as a whole, no matter how many products it contains. They live on the carousel's own trackers field, each as an array of trackers (a carousel can carry more than one tracker for the same event), and are always pixel trackers.
for (const carousel of carouselsRes.value) {
carousel.trackers
.win
.filter(t => t.type === 'pixel')
.forEach(t => res.write(`<img src="${t.url}" width="1" height="1" style="display:none">`));
carousel.trackers
.impression
.filter(t => t.type === 'pixel')
.forEach(t => res.write(`<img src="${t.url}" width="1" height="1" style="display:none">`));
for (const item of carousel.products) {
// ...render each product card
}
}
Or, from the client side, fire them with fetch immediately before the carousel renders:
carousel.trackers.win.filter(t => t.type === 'pixel').forEach(t => fetch(t.url));
carousel.trackers.impression.filter(t => t.type === 'pixel').forEach(t => fetch(t.url));
Firing win or impression per product card instead of once per carousel will overcount — don't loop these over carousel.products.
Viewability
Unlike win and impression, viewability is tracked per product card, not per carousel — each card can scroll into view independently, so each needs its own check. Don't confuse this with the carousel-level impression tracker above: viewability pixels live on the card itself, under card.trackers.namedTrackers.viewable, filtered to pixel type:
card.trackers
?.namedTrackers
?.viewable
?.filter(t => t.type === 'pixel')
As with banner viewability, fire these once the card meets your viewability threshold, using an IntersectionObserver per card rather than a static pixel:
// Adapt this for your framework
for (const card of carousel.products) {
const viewabilityTrackers = card.trackers
?.namedTrackers
?.viewable
?.filter(t => t.type === 'pixel') ?? [];
if (!viewabilityTrackers.length) {
continue;
}
let timer;
let hasFired = false;
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting && entry.intersectionRatio > 0.5 && !hasFired) {
timer = setTimeout(() => {
viewabilityTrackers.forEach(t => navigator.sendBeacon(t.url));
hasFired = true;
}, 1_000); // dwell time, 1 second
} else {
clearTimeout(timer);
timer = undefined;
}
});
}, { threshold: 0.5 }); // 50% viewable
observer.observe(cardElement);
}
SurfsideCarousel renders a scrollable product carousel. It supports three strategies that control where the products come from.
import { SurfsideCarousel } from '@surfside/ads-react';
<SurfsideCarousel
zoneId="your-zone-id"
strategy="hybrid"
recommender="top-products"
cardMinWidth={260}
carouselLimits={{ type: 'infinite' }}
nextType="product"
/>
Requires a SurfsideProvider2 ancestor.
Strategies
The strategy prop determines where products come from.
hybrid
Leads with sponsored products from the RTB bidder. Once sponsored inventory is exhausted, the carousel fills from the recommender. This is the most common strategy for commerce media placements.
<SurfsideCarousel
zoneId="your-zone-id"
strategy="hybrid"
recommender="top-products"
cardMinWidth={260}
carouselLimits={{ type: 'infinite' }}
nextType="product"
/>
recommended
Pulls entirely from the recommender — no RTB bid is sent. Use this for organic shelves that should surface relevant products without a sponsored layer.
<SurfsideCarousel
zoneId="your-zone-id"
strategy="recommended"
recommender="for-you"
cardMinWidth={260}
carouselLimits={{ type: 'infinite' }}
nextType="product"
/>
sponsored
Pulls entirely from the RTB bidder. The carousel ends when the bidder has no more products to return. Use this for explicitly sponsored shelves.
<SurfsideCarousel
zoneId="your-zone-id"
strategy="sponsored"
recommender={undefined}
cardMinWidth={260}
carouselLimits={{ type: 'limited', limit: 10 }}
nextType="page"
/>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
zoneId | string | Yes | The zone ID of the placement |
strategy | 'hybrid' \| 'recommended' \| 'sponsored' | Yes | Where products come from — see Strategies above |
recommender | 'top-products' \| 'for-you' \| 'past-purchases' | Required for hybrid and recommended | Which recommender to use when filling from recommendations. Pass undefined for sponsored |
cardMinWidth | number | Yes | Minimum width of a product card in pixels. Controls how many cards fit per row |
carouselLimits | CarouselLimits | Yes | Controls pagination behavior — see below |
nextType | 'product' \| 'page' | Yes | Controls what "next" loads: one product at a time (product) or a full page of cards (page) |
category | string[] | No | Category targeting hints passed to the bidder |
enabled | boolean | No | When false, the component renders nothing. Default true |
Recommenders
When strategy is hybrid or recommended, the recommender prop controls which recommendation signal fills the carousel:
| Value | Description |
|---|---|
top-products | The most popular products on the site |
for-you | Products personalized for the current user. Falls back to top-products if no userId is set on the provider |
past-purchases | Products based on the user's purchase history. Falls back to top-products if no userId is set |
carouselLimits
carouselLimits controls how many products the carousel loads and how it pages through them.
// No limit — the carousel loads more as the user scrolls or pages
carouselLimits={{ type: 'infinite' }}
// Fixed limit — the carousel stops after N products
carouselLimits={{ type: 'limited', limit: 12 }}
Category targeting
Pass category to narrow which sponsored products and recommendations are eligible:
<SurfsideCarousel
zoneId="your-zone-id"
strategy="hybrid"
recommender="top-products"
cardMinWidth={260}
carouselLimits={{ type: 'infinite' }}
nextType="product"
category={['edibles', 'beverages']}
/>
The React Hooks workflow doesn't have a dedicated useSurfsideCarousel hook. Instead, useSurfsideProducts fetches a paginated product feed — sponsored, recommended, or a hybrid of both — and returns the products along with callbacks for fetching more and tracking events. Use it to build a carousel or any other custom product listing layout.
import { useSurfsideProducts } from '@surfside/ads-react';
const { products, loading, error, hasMore, fetchNextPage, analytics } = useSurfsideProducts({
zoneId: 'your-zone-id',
strategy: 'sponsored',
pageSize: 6
});
if (loading) {
return <div>Loading...</div>;
}
if (error !== undefined) {
throw error.error;
}
return (
<div>
{products.map((product) => (
<div key={product.data.id}>
<img src={product.data.image} alt={product.data.name} />
<h2>{product.data.name}</h2>
<p>{product.data.details}</p>
{product.type === 'sponsored' && <span>Sponsored</span>}
</div>
))}
{hasMore && <button onClick={fetchNextPage}>Load more</button>}
</div>
);
Requires a SurfsideProvider2 ancestor.
Options
| Option | Type | Required | Description |
|---|---|---|---|
zoneId | string | Yes | Zone ID of the placement |
pageSize | number | Yes | Number of products to fetch per page |
strategy | 'sponsored' \| 'hybrid' \| 'recommended' | Yes | Feed strategy (see below) |
enabled | boolean | No | When false, no request is made. Default true |
maxProducts | number | No | Total product cap across all pages |
recommenderType | 'top-products' \| 'for-you' \| 'past-purchases' | No | Recommender strategy for hybrid and recommended strategies. Default 'top-products' |
withProductInfo | boolean | No | When true, returns full product data. When false, returns only product IDs. Default true |
category | string[] | No | Category targeting hints passed to the bidder |
Strategy
| Value | Behavior |
|---|---|
'sponsored' | Returns only sponsored (advertised) products |
'recommended' | Returns only recommended products from the recommender engine |
'hybrid' | Fills with sponsored products first, falls back to recommended when none are available |
Return values
| Field | Type | Description |
|---|---|---|
products | IProductInfoData[] \| IProductIDData[] | The current page of products |
loading | boolean | true while the initial fetch is in flight |
error | ErrResult \| undefined | Set if an error occurs |
hasMore | boolean | true if more products are available to fetch |
fetchNextPage | () => void \| undefined | Appends the next page to products. Defined only on success |
refetch | () => void | Resets the feed and refetches from the first page |
analytics | IProductAnalyticsCallbacks \| undefined | Tracking callbacks. Defined only on success |
Product data
When withProductInfo is true (the default), each product in the array has:
{
type: 'sponsored' | 'recommended',
data: {
id: string,
name: string,
image: string, // URL
details: string, // short description
price: string,
// ... additional product fields
}
}
When withProductInfo is false, only the ID is returned:
{
type: 'sponsored' | 'recommended',
data: { id: string }
}
Labeling sponsored products
US FTC guidelines require that sponsored content be conspicuously labeled. Use the type field to distinguish sponsored from recommended products:
{product.type === 'sponsored' && <span>Sponsored</span>}
Tracking
The analytics object provides three callbacks. All are defined when the hook is in a success state.
Win
Call trackWin immediately before a sponsored product renders — the SDK's win contract requires the call to fire before the product is painted, not after. Pass the full product object, and use useLayoutEffect rather than useEffect: useLayoutEffect runs synchronously after the DOM is updated but before the browser paints, while useEffect runs after paint and would fire the call too late.
useLayoutEffect(() => {
for (const product of products) {
if (product.type === 'sponsored') {
analytics?.trackWin(product);
}
}
}, [products, analytics]);
Impression
Call trackImpression when a product scrolls into view or otherwise becomes visible:
// Using IntersectionObserver or a visibility library
analytics?.trackImpression(product);
Click
Call trackClick when a user clicks on a product:
<div onClick={() => analytics?.trackClick(product)}>
{product.data.name}
</div>
Pagination
hasMore is true when there are additional products available. Call fetchNextPage to append the next batch to the products array:
{hasMore && (
<button onClick={fetchNextPage}>Load more</button>
)}
Set maxProducts to cap the total number of products the feed will ever return, regardless of how many times fetchNextPage is called.
<surf-carousel> renders a scrollable product carousel. Like the React component, it supports the same three strategies for where products come from, and handles the bid request, rendering, and tracking internally.
<surf-carousel
account-id="your-account-id"
site-id="your-site-id"
channel-id="your-channel-id"
location-id="your-location-id"
zone-id="your-zone-id"
strategy="hybrid"
recommend="top-products"
card-min-width="260"
next-type="product"
></surf-carousel>
Requires the Surfside script tag — see Initialization.
Strategies
The strategy attribute determines where products come from — the same three strategies described under the React Components tab above (hybrid, sponsored, or recommended). It defaults to hybrid if omitted.
Attributes
| Attribute | Required | Default | Description |
|---|---|---|---|
account-id, site-id, channel-id, location-id | Yes | — | Your Surfside account identifiers — see Initialization |
zone-id | Yes | — | The zone ID of the placement |
strategy | No | hybrid | hybrid, sponsored, or recommended — where products come from |
recommend | No | top-products | top-products, for-you, or past-purchases — which recommender to use when filling from recommendations |
card-min-width | No | 300 | Minimum width of a product card in pixels |
card-max-width | No | — | Maximum width of a product card in pixels |
max-items | No | Unlimited | Caps the total number of products loaded. Omit or set to 0 for infinite scroll |
next-type | No | page | product or page — controls what "next" loads |
category | No | — | Comma-separated category targeting hints passed to the bidder |
keywords | No | — | Comma-separated keyword targeting hints passed to the bidder |
Updating a placement
Attributes are reactive. Changing strategy, recommend, category, or any other attribute on an already-connected <surf-carousel> from JavaScript automatically triggers a new bid request and re-renders the carousel.
No fill
If the carousel comes back with no products, the component removes itself from the page rather than rendering an empty container.