Implementing Banners
- Headless
- React Components
- React Hooks
- Web Components
This section covers the request options and response fields specific to banner creatives. For how to build and send a bid request, see Building Bid Requests.
Requesting a banner
Use .andBanner(width, height) on an impression to ask for a banner, sized as an aspect ratio (4x1, 8x1, etc.). A request typically covers more than the banner slot alone — here, a banner alongside a product carousel:
/* const ads = await LoadSurfAdsCoreHeadless({ ... }) — see Initialization */
const request = ads.startRequest('your-zone-id')
.withImp('banner-zone-id')
.andBanner(4, 1)
.withImp('carousel-zone-id')
.andProductCards(ads.defaultNative())
.build();
const bidsRes = await ads.requestBids(request);
if (!bidsRes.ok) {
console.error(bidsRes.error);
return;
}
You can ask for more than one banner size on the same impression — the bidder returns whichever it can fill:
const request = ads.startRequest('your-zone-id')
.withImp('banner-zone-id')
.andBanner(4, 1)
.andBanner(8, 1)
.withImp('carousel-zone-id')
.andProductCards(ads.defaultNative())
.build();
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('banner-zone-id')
.andBanner(4, 1)
.withImp('carousel-zone-id')
.andProductCards(ads.defaultNative())
.build();
The zone ID identifies which ad placement to fill. You can find your zone IDs in the Surfside dashboard. The width and height should match the dimensions of the placement.
Building banner responses
buildAllBannersCore takes the full bid response and returns a Result containing an array of built banners, pulling out just the banner creatives even if the request also asked for other creative types. Each banner includes an adm field — the HTML of the creative — and an optional clickthrough field if the creative uses a commerce media clickthrough URL.
const bannersRes = ads.buildAllBannersCore(bidsRes.value);
if (!bannersRes.ok) {
console.error(bannersRes.error);
return;
}
If the request mixed creative types on the same impression, use buildAllResponsesCore instead (see Building Bid Requests) and switch on creative.type === 'banner'.
Writing the output
banner.adm is the HTML string for the creative. Write it to the response however your server framework expects:
for (const banner of bannersRes.value) {
res.write(banner.adm);
}
Win notification
Each banner also has a win field — a URL that must be called immediately before the creative renders on the page. This is how Surfside tracks impressions, and it is important that it fires before the ad is rendered, not after.
The simplest approach is to embed it as a 1×1 tracking pixel alongside the creative HTML:
for (const banner of bannersRes.value) {
res.write(`${banner.adm}<img src="${banner.win}" width="1" height="1" style="display:none">`);
}
Alternatively, fire it with a fetch call from the client side immediately before inserting the ad into the page:
fetch(banner.win);
There is no meaningful response, so you do not need to await it or handle errors.
Viewability tracking
Each banner also has a viewable field — a URL that must be called when the creative meets viewability (50% viewable for 1 second). This is separate from the win notification: win confirms the ad was served and rendered, while viewable confirms it was actually seen by the user.
Because viewability depends on scroll position and dwell time, it's typically fired from an IntersectionObserver watching the rendered creative's container, rather than embedded as a static pixel:
// Adapt this for your framework
let timer;
let hasFired = false;
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (
entry.isIntersecting &&
entry.intersectionRatio > 0.5 &&
!hasFired && banner.viewable !== undefined
) {
timer = setTimeout(() => {
navigator.sendBeacon(banner.viewable);
hasFired = true;
}, 1_000); // dwell time, 1 second
} else {
clearTimeout(timer);
timer = undefined;
}
});
}, { threshold: 0.5 }); // 50% viewable
observer.observe(bannerElement);
navigator.sendBeacon is preferable to fetch here since it's more likely to complete even if the user navigates away right as the timer fires. As with win, there is no meaningful response, so you do not need to handle errors.
SurfsideBanner renders a banner ad for a given zone. It handles the bid request, win tracking, and error states internally.
import { SurfsideBanner } from '@surfside/ads-react';
<SurfsideBanner
zoneId="your-zone-id"
size={[4, 1]}
/>
Requires a SurfsideProvider2 ancestor.
Props
| Prop | Type | Required | Description |
|---|---|---|---|
zoneId | string | Yes | The zone ID of the placement |
size | [number, number] | Yes | The requested banner size as an aspect ratio [width, height] — e.g. [4, 1], [1, 1] |
category | string[] | No | Category targeting hints passed to the bidder |
enabled | boolean | No | When false, the component renders nothing. Default true |
renderLoading | () => ReactNode | No | Rendered while the bid request is in flight |
renderError | () => ReactNode | No | Rendered when no bid is returned or an error occurs |
onRender | (bannerId: string) => void | No | Called when the creative is successfully rendered |
onWin | (bannerId: string) => void | No | Called when the win notification is tracked |
onError | (error: ErrResult) => void | No | Called when an error occurs or no bid is returned |
Size
size is an aspect ratio, not a pixel dimension. The banner fills the width of its container and adjusts its height accordingly. [4, 1] is a standard leaderboard ratio; [1, 1] is square.
Loading and error states
By default the component renders nothing while loading and nothing on error, so the slot collapses. Provide renderLoading and renderError to control what appears in those states:
<SurfsideBanner
zoneId="your-zone-id"
size={[4, 1]}
renderLoading={() => (
<div style={{ width: '100%', aspectRatio: '4 / 1', background: '#f0f0f0' }} />
)}
renderError={() => null}
/>
Category targeting
Pass category to narrow which ads are eligible for the placement:
<SurfsideBanner
zoneId="your-zone-id"
size={[4, 1]}
category={['edibles']}
/>
Callbacks
Use onRender and onWin to hook into the banner lifecycle — for example, to record analytics or log activity:
<SurfsideBanner
zoneId="your-zone-id"
size={[4, 1]}
onRender={(id) => analytics.track('banner_render', { id })}
onWin={(id) => analytics.track('banner_win', { id })}
onError={(err) => console.warn('No banner filled', err.error)}
/>
Win tracking fires automatically. The onWin callback is for any additional side effects on your end.
Disabling a placement
Set enabled={false} to suppress a placement without removing the component from the tree — for example, to conditionally hide ads based on user consent state:
<SurfsideBanner
zoneId="your-zone-id"
size={[4, 1]}
enabled={hasConsent}
/>
useSurfsideBanner fetches a banner creative and returns the markup, loading state, and analytics callbacks. Use it when you need direct control over rendering rather than the SurfsideBanner component.
import { useLayoutEffect } from 'react';
import { useSurfsideBanner } from '@surfside/ads-react';
const { banner, loading, error, analytics } = useSurfsideBanner({
zoneId: 'your-zone-id',
size: [4, 1]
});
useLayoutEffect(() => {
analytics?.trackWin();
}, [analytics]);
if (loading) {
return <div>Loading...</div>;
}
if (error !== undefined) {
throw error.error;
}
return <div dangerouslySetInnerHTML={{ __html: banner.adm }} />;
Requires a SurfsideProvider2 ancestor.
Options
| Option | Type | Required | Description |
|---|---|---|---|
zoneId | string | Yes | Zone ID of the placement |
size | [number, number] | Yes | Requested aspect ratio [width, height] — e.g. [4, 1], [16, 9] |
category | string[] | No | Category targeting hints passed to the bidder |
enabled | boolean | No | When false, no request is made. Default true |
Return values
The hook returns a discriminated union across three states. TypeScript will narrow the types when you check loading and error.
| Field | Type | Description |
|---|---|---|
banner | IBanner \| undefined | The banner creative. Defined only on success |
loading | boolean | true while the bid request is in flight |
error | ErrResult \| undefined | Set when an error occurs or no bids are returned |
analytics | IBannerAnalyticsCallbacks \| undefined | Analytics callbacks. Defined only on success |
refetch | () => Promise<void> | Re-runs the bid request using the same options |
Rendering the creative
banner.adm is an HTML string. Use dangerouslySetInnerHTML to inject it:
return <div dangerouslySetInnerHTML={{ __html: banner.adm }} />;
Win tracking
Call analytics.trackWin() immediately before the banner renders — the SDK's win contract requires the call to fire before the creative is painted, not after. 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 win call too late. The hook fires this on your behalf only if you call it — the component handles this automatically, but the hook does not.
useLayoutEffect(() => {
analytics?.trackWin();
}, [analytics]);
Pass an error handler if you want to observe failures:
analytics?.trackWin((err) => console.error('Win tracking failed', err));
Re-fetching
refetch replays the same bid request. Use it to re-auction on a fixed interval:
useEffect(() => {
const timer = setTimeout(refetch, 30_000);
return () => clearTimeout(timer);
}, [refetch]);
Lazy loading
Set enabled={false} to defer the request until a condition is met — for example, until the placement scrolls into view:
const { banner, loading, error, analytics } = useSurfsideBanner({
zoneId: 'your-zone-id',
size: [4, 1],
enabled: isVisible
});
<surf-banner> renders a banner ad for a given zone. Like the React component, it handles the bid request, rendering, and win/viewability tracking internally — you just place the element on the page.
<surf-banner
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"
width="4"
height="1"
></surf-banner>
Requires the Surfside script tag — see Initialization.
Attributes
| Attribute | Required | 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 |
width | Yes | The requested banner width, as part of an aspect ratio — e.g. width="4" |
height | Yes | The requested banner height, as part of an aspect ratio — e.g. height="1" |
category | No | Comma-separated category targeting hints passed to the bidder |
keywords | No | Comma-separated keyword targeting hints passed to the bidder |
Size
width and height express an aspect ratio, not a pixel dimension — the same convention as the Headless and React workflows. width="4" height="1" is a standard leaderboard ratio.
Updating a placement
Attributes are reactive. Changing zone-id, category, or keywords on an already-connected <surf-banner> from JavaScript automatically triggers a new bid request and re-renders the placement — there's no imperative refresh method to call.
No fill
If no bid fills the placement, the component renders nothing. There's no error event to listen for — if you need to detect an empty placement, check whether the element's content is empty after it settles.