Skip to main content
Version: 10.0

How to develop an AXIS frontend

Goal

Build a web or app frontend that consumes AXIS Frontend APIs, renders content experiences, passes user context correctly, and handles loading, not-found, server, network, and post-publish consistency cases.

AXIS Frontend APIs can power:

  • Home page carousels and rails
  • Content detail pages
  • Show, season, and episode navigation
  • Search
  • Live TV schedules
  • CMS pages and navigation config
  • User-scoped features such as bookmarks and continue watching
  • Image rendering through Shain

Available APIs

All of the following are frontend-facing services. They are public, read-only except user data, and served behind the CDN with no tenant ID in the URL.

ServiceWhat it doesSample endpoints
axis-api-catalogContent catalog: items, item lists, and search./v1/items, /v1/lists, /v1/search
axis-api-displayCMS pages with resolved layout entries and platform navigation config./v1/page, /v1/config
axis-api-linearLive and scheduled TV: channel grids and now-playing./v1/schedules, /v1/schedules/{id}
axis-api-searchFull-text search across the catalog./v1/search/
axis-api-uxmetaUser-scoped data: bookmarks, continue watching, follows, ratings. Requires JWT./v1/bookmarks, /v1/continue-watching, /v1/watched, /v1/ratings
axis-svc-shainImage rendering and transformation: resize, crop, format conversion, overlays./v1/dataservice/ResizeImage/$value

Note: axis-api-media is a backend service used for uploading and managing raw image assets. It is not called directly by frontend applications. Image rendering for display is handled by axis-svc-shain.

Recommended App Structure

LayerResponsibility
App shellRouting, layout, navigation, and global app setup.
API client layerCalls AXIS APIs and normalizes common request handling.
Request context layerProvides lang, max_rating, device, and sub for requests.
User auth layerProvides the JWT required for user-scoped endpoints.
Feature modulesHome, detail, search, live TV, pages/navigation, and user-data features.
UI componentsCards, carousels, lists, detail views, loading states, not-found states, and error states.

Step-by-step

Step 1: Configure environments

Store the API host and confirmed API version per environment.

development: https://{dev-api-host}/{apiVersion}
staging: https://{staging-api-host}/{apiVersion}
production: https://{production-api-host}/{apiVersion}

All API endpoints are available at a single versioned base URL:

https://{api-host}/v1/

{api-host} is environment-specific. Your project team will provide the correct value for development, staging, and production. There is no tenant ID in the URL. Tenant routing is handled transparently by the CDN.

All responses are JSON. Catalog, list, schedule, and most display calls are GET. Search uses POST. User-data endpoints require a JWT.

Quick smoke test: fetch an item list.

const response = await fetch('https://{api-host}/v0.1/lists/featured?lang=en');
const list = await response.json();
console.log(list.items); // array of content items

Step 2: Build a shared API client

Create a wrapper for common fetch behavior.

const API_HOST = 'https://{api-host}';
const API_VERSION = '{apiVersion}';

async function axisApi(path, options = {}) {
const url = `${API_HOST}/${API_VERSION}${path}`;

let response;

try {
response = await fetch(url, options);
} catch {
throw new Error('Network error - check your connection and try again.');
}

if (response.status === 404) return null;

if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message ?? `Request failed with status ${response.status}`);
}

return response.status === 204 ? null : response.json();
}

Use this wrapper to keep response handling consistent across item, list, search, page, schedule, and user-data calls.

Step 3: Pass common query parameters

Build query parameters from the current user and device context. The source guide says these common parameters work across most endpoints. Pass them on every request where the endpoint accepts them.

const params = new URLSearchParams({
lang,
device,
...(maxRating && { max_rating: maxRating }),
...(sub && { sub })
});
ParameterExampleDescription
langen-gb, fr-frThe language to return content in.
max_ratingauoflc-pg, mpaa-rMaximum content rating the current user should see. Items above this rating are excluded server-side.
deviceweb_browser, ios, android, tvThe device type. Affects which offers are returned. Defaults to web_browser.
substandard, premiumComma-separated active subscription codes for the current user. Filters the offers array on each item.

Use max_rating for signed-in users with parental controls. Use sub to filter the offers array based on active subscription codes.

Step 4: Understand core content concepts

Items

An item is any piece of content: a movie, show, season, episode, event, live channel, or custom asset. Items have a type field that tells you what kind of content they are.

type: "movie" | "show" | "season" | "episode" | "program" | "trailer" |
"channel" | "customAsset" | "event" | "competition" | "stage" |
"persona" | "team" | ...

Item Lists

An item list is a curated or rules-based collection of items. Think of a carousel row on a home page. Lists have their own ID and slug, and support pagination.

Slugs vs IDs

Most resources can be fetched either by ID, usually a UUID, or by slug, a human-readable URL-safe string such as the-dark-knight. Use slugs for URL routing in your app. Use IDs when you have them from another API call.

Offers

Each item carries an offers array listing how it can be accessed: subscription, rental, purchase, or free. Filter this array using the sub parameter so the frontend shows only what the current user can access.

Images

Images are returned as a { [imageType]: url } dictionary on items and lists. Common keys include poster, hero, thumbnail, and banner. URLs point to axis-svc-shain, the platform image rendering service. Use them directly in <img> tags, or modify transformation options when different sizes or formats are required.

Step 5: Fetch app config and navigation

Global navigation config can be fetched at app startup. The config includes navigation menus, classification ratings, supported languages, and other configured metadata.

async function fetchConfig(lang = 'en') {
const params = new URLSearchParams({
lang,
include: 'classification,general,..'
});

return axisApi(`/config?${params}`);
}

Original expanded example:

async function fetchConfig(lang = 'en') {
const params = new URLSearchParams({
lang,
include: 'classification,general,..',
});
const response = await fetch(`https://{api-host}/v0.1/config?${params}`);
return response.json();
}

// The config includes navigation menus, classification ratings, supported languages, etc.
const config = await fetchConfig('en');
const mainNav = config.navigation?.header;
const ratingSystem = config.classification?.ratingSystems?.[0];

Pages can be fetched by path to get CMS-managed layout entries with item list references resolved.

async function fetchPage(pagePath, { lang, maxRating, device, sub } = {}) {
const params = new URLSearchParams({
path: pagePath,
lang: lang ?? 'en',
...(maxRating && { max_rating: maxRating }),
...(device && { device: device }),
...(sub && { sub }),
});
const response = await fetch(`https://{api-host}/page?${params}`);
if (response.status === 404) return null;
return response.json();
}

const homePage = await fetchPage('/', { lang: 'en' });

Step 6: Build the Home page

A home page typically displays multiple content rails. These rails can be loaded either by calling the batch lists endpoint directly, or by calling the page endpoint and letting the page response include resolved layout entries and selected list data.

Option A: Fetch lists directly

Use the batch lists endpoint when the app already knows which lists it needs.

const params = new URLSearchParams({
ids: 'featured|page_size=5,new-releases|page_size=20,trending',
lang: 'en-gb',
device: 'web_browser',
max_rating: userMaxRating,
sub: userSubscription
});

const lists = await axisApi(`/lists/?${params}`);

Original expanded example:

// Fetch multiple rails in one call
const params = new URLSearchParams({
ids: 'featured|page_size=5,new-releases|page_size=20,trending,continue-watching',
lang: 'en-gb',
device: 'web_browser',
max_rating: userMaxRating, // e.g. 'auoflc-pg'
sub: userSubscription, // e.g. 'standard'
});

const response = await fetch(`https://{api-host}/v0.1/lists/?${params}`);
const lists = await response.json(); // ItemList[]

lists.forEach(list => {
console.log(`${list.title}: ${list.items.length} items (${list.size} total)`);
});

Render each list as a carousel or rail.

Use paging.next to load more items. Do not construct pagination URLs manually.

Per-list options using pipe encoding:

const ids = [
'featured|page_size=5',
'new-releases|page_size=20|order=desc',
'action-movies|page_size=20|genre:action',
].join(',');

Rendering a carousel:

function renderCarousel(list) {
return `
<section class="carousel" data-list-id="${list.id}">
<h2>${list.title}</h2>
<div class="items">
${list.items.map(item => renderCard(item)).join('')}
</div>
${list.paging.next ? `<button data-next="${list.paging.next}">Load more</button>` : ''}
</section>
`;
}

function renderCard(item) {
const poster = item.images?.poster ?? item.images?.thumbnail ?? '';
return `
<a href="${item.path}" class="card">
<img src="${poster}" alt="${item.title}" loading="lazy" />
<span class="title">${item.title}</span>
${item.badge ? `<span class="badge">${item.badge}</span>` : ''}
</a>
`;
}

Option B: Fetch a CMS page with list prefetching

A common scenario used by frontend apps is to request a CMS page by path and control how many list items are hydrated in the initial response. Example request:

https://{host}/v1/api/page?device=web_browser&lang=da&list_page_size=24&max_list_prefetch=3&path=/&segments=drtv,optedin&sub=Registered&text_entry_format=html

Important query parameters:

ParameterPurpose
list_page_size=24Loads a maximum of 24 items for each list included in the page response. The API should not return more than this number of items per list.
max_list_prefetch=3Loads item results only for the first 3 lists on the page. For the remaining lists, the page response should include only list metadata and item counts.
path=/Identifies the CMS page to load. For the home page, this is usually /.
segments=drtv,optedinPasses the active segmentation context so the page response matches the current audience or app experience.
sub=RegisteredPasses the current subscription or user state context so offers and page content can be filtered correctly.
text_entry_format=htmlRequests text entries in HTML format where supported.

This approach helps the frontend render the first screen quickly without loading every rail in full. After the initial page response, the frontend should lazy load additional lists only when needed. For example, when a user scrolls near lower rails, the app can call the list API with a limited set of list IDs. Example request:

https://{host}/v1/api/lists?device=web_browser&ids=288a1a39-e77f-87a8-b141-9714197b8a70%2C333c8913-0213-86db-bac2-f38630236bd1%2C33aa200f-2b56-8950-a831-eb76e475028f%2C3c5c5906-d7cb-8bdc-83f0-ef302ad51cc0%2C3c8fc2d4-1ad4-8f5f-b9d6-86da34e52b97&isDeviceAbroad=true&lang=da&segments=drtv%2Coptedin&sub=Registered

Pass only the list IDs that the frontend needs to load next. Do not pass all list IDs from the page response if the user has not reached those rails yet.

A typical frontend loading flow is:

  1. Fetch the page using /v1/api/page.
  2. Render the page layout and the prefetched lists.
  3. Render unloaded lists using their metadata, title, and loading state.
  4. When a list is about to enter the viewport, request that specific list through /v1/api/lists.
  5. Append the loaded list items into the existing page state.
  6. Use paging.next for additional pagination within each list.

Step 7: Build the Detail page

Use the item ID or slug from the URL to retrieve item details. Fetch by ID:

async function fetchItemDetail(itemId, { lang, maxRating, device, sub } = {}) {
const params = new URLSearchParams({
expand: 'all',
lang: lang ?? 'en-gb',
...(maxRating && { max_rating: maxRating }),
...(device && { device }),
...(sub && { sub }),
});

const response = await fetch(`https://{api-host}/v0.1/items/${itemId}?${params}`);

if (response.status === 404) return null;
if (!response.ok) throw new Error(`API error: ${response.status}`);

return response.json();
}

Fetch by slug when routing from a URL such as /movies/the-dark-knight:

async function fetchItemBySlug(itemType, slug, options) {
const params = new URLSearchParams({ expand: 'all', lang: options.lang ?? 'en' });
const response = await fetch(
`https://{api-host}/v0.1/items/${itemType}/slug/${slug}?${params}`
);
if (response.status === 404) return null;
return response.json();
}

Reviewed wrapper-based version:

async function fetchItemBySlug(itemType, slug, options) {
const params = new URLSearchParams({
expand: options.expand ?? 'all',
lang: options.lang ?? 'en',
...(options.maxRating && { max_rating: options.maxRating }),
...(options.device && { device: options.device }),
...(options.sub && { sub: options.sub })
});

return axisApi(`/items/${itemType}/slug/${slug}?${params}`);
}

For show-type content, expand=all can retrieve the complete hierarchy in one request.

For a show, the response includes:

  • item.seasons: an ItemList containing season summaries
  • Each season can include episode lists
  • item.show: populated when fetching an episode or season, giving parent context

For non-hierarchical types such as movie or event, expand=all is ignored.

Rendering the detail page:

function renderDetailPage(item) {
return `
<article class="detail">
<div class="hero" style="background-image: url(${item.images?.hero})">
<h1>${item.title}</h1>
<p class="tagline">${item.tagline ?? ''}</p>
</div>
<div class="meta">
<span class="rating">${item.classification?.code ?? ''}</span>
${item.releaseYear ? `<span class="year">${item.releaseYear}</span>` : ''}
${item.duration ? `<span class="duration">${formatDuration(item.duration)}</span>` : ''}
<p class="description">${item.description}</p>
</div>
${renderOffers(item.offers)}
${item.seasons ? renderSeasonList(item.seasons) : ''}
${item.episodes ? renderEpisodeList(item.episodes) : ''}
</article>
`;
}

function formatDuration(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}

function renderOffers(offers) {
if (!offers?.length) return '';
const primaryOffer = offers[0];
return `
<div class="offers">
<a href="${primaryOffer.watchPath ?? '#'}" class="cta-btn">
${primaryOffer.price === 0 ? 'Watch Free' : `Rent from $${primaryOffer.price}`}
</a>
</div>
`;
}

Step 8: Build show, season, and episode navigation

Efficient navigation through show hierarchies is important for both performance and user experience.

Choosing the right expansion strategy:

  • expand=all returns the complete hierarchy, including the show, seasons, and episodes, in a single response.
  • expand=children returns only the immediate child items and is typically more efficient for navigation scenarios.
  • expand=parent returns the immediate parent item.
  • expand=ancestors can be used when the full parent hierarchy is required.

Note: While expand=all can simplify implementation by reducing the number of API calls, it is also the most expensive option and may result in large payloads for shows with many seasons and episodes. For production applications, consider loading hierarchy data incrementally where possible.

First load: deep-link to a show, for example /shows/breaking-bad.

// expand=all gives: show + season list + each season's episode list
const show = await fetchItemBySlug('show', 'breaking-bad', { expand: 'all', lang: 'en' });

// show.seasons.items = [Season 1, Season 2, ...]
// Each season item has basic metadata; the active season has its episodes loaded

User navigates to a different season:

// Only fetch children — no need to reload the full tree
const params = new URLSearchParams({ expand: 'children', lang: 'en', page_size: '20' });
const season = await fetch(`https://{api-host}/v0.1/items/${seasonId}?${params}`);
// season.episodes.items = [Episode 1, Episode 2, ...]

User navigates to a specific episode:

// Get episode + its parent season context
const params = new URLSearchParams({ expand: 'parent', lang: 'en' });
const episode = await fetch(`https://{api-host}/v0.1/items/${episodeId}?${params}`);
// episode.season = { id, title, seasonNumber, ... }
// episode.show = null (use 'ancestors' if you also need the show)

Getting the latest season of a show to pre-select on the show page:

const params = new URLSearchParams({ select_season: 'latest', expand: 'all', lang: 'en' });
const season = await fetch(`https://{api-host}/v0.1/items/${showId}?${params}`);
// Returns the latest season with its episodes, plus the parent show context

Search uses POST.

async function search({
term,
types,
lang,
maxRating,
sub,
device,
page = 1,
pageSize = 20
}) {
return axisApi('/search/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
term,
userContext: {
maxRating,
subscriptions: sub ? sub.split(',') : [],
device: device ?? 'web_browser'
},
filterContext: {
includeTypes: types ?? []
},
page,
pageSize,
languageCode: lang ?? 'en'
})
});
}

Original expanded example:

async function search({ term, types, lang, maxRating, sub, device, page = 1, pageSize = 20 }) {
const response = await fetch(`https://{api-host}/v0.1/search/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
term,
userContext: {
maxRating,
subscriptions: sub ? sub.split(',') : [],
device: device ?? 'web_browser',
},
filterContext: {
includeTypes: types ?? [], // e.g. ['movie', 'show']
},
page,
pageSize,
languageCode: lang ?? 'en',
}),
});

if (!response.ok) throw new Error(`Search error: ${response.status}`);
return response.json(); // { items: ItemSummary[], totalResults, page, pageSize }
}

Usage:

// Basic search
const results = await search({ term: 'batman', lang: 'en' });

// Type-filtered search (movies and shows only)
const filtered = await search({ term: 'action', types: ['movie', 'show'], lang: 'en' });

// Paginate
const page2 = await search({ term: 'batman', page: 2, pageSize: 20, lang: 'en' });

For typeahead search, debounce user input before sending requests. The source example uses:

  • A 300 ms debounce
  • No request when the input has fewer than two characters
  • A smaller page size for suggestions
let searchTimeout;

function onSearchInput(value) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(async () => {
if (value.length < 2) return;
const results = await search({ term: value, lang: 'en', pageSize: 5 });
renderSearchSuggestions(results.items);
}, 300);
}

Step 10: Build live TV views

Fetch currently live items.

const liveItems = await axisApi('/schedules/live?lang=en-gb');

Original expanded example:

// Get all currently live items
const params = new URLSearchParams({ lang: 'en-gb' });
const response = await fetch(`https://{api-host}/v0.1/schedules/live?${params}`);
const liveItems = await response.json();

// liveItems is an array of schedule entries, each with:
// .channelId, .title, .startAt, .endAt, .live, .itemId, .itemType

For schedule grids, pass parameters such as:

  • channels
  • date
  • hour
  • duration
  • segments, where used
  • device, where used
  • sub, where used

Fetching a channel grid for a time window:

const params = new URLSearchParams({
lang: 'en-gb',
channels: [channelids], // The list of channel ids to get schedules for.
date: "date", // The date to target in ISO format, e.g. 2017-05-23
hour: 0-23, // The base hour in the day, defined by the date parameter, you wish to load schedules for.
duration: -24-24, // This may be negative or positive depending on whether you want to load past or future schedules.
...(segments && { segments: segments }),
...(device && { device }),
...(sub && { sub }),
});
const response = await fetch(`https://{api-host}/v0.1/schedules/?${params}`);
const grid = await response.json();

Fetching schedule by ID:

const params = new URLSearchParams({
lang: 'en-gb',
id: 'schedule_id', // The identifier of the item schedule to load.
...(segments && { segments: segments }),
...(device && { device }),
...(sub && { sub }),
});
const response = await fetch(`https://{api-host}/v0.1/schedules/?${params}`);
const grid = await response.json();

Live data changes every programme slot. The source example refreshes live data every 60_000 ms.

let liveInterval;

async function startLiveRefresh(onUpdate, intervalMs = 60_000) {
const refresh = async () => {
const response = await fetch('https://{api-host}/v0.1/schedules/live?lang=en-gb');
if (response.ok) onUpdate(await response.json());
};

await refresh(); // immediate first load
liveInterval = setInterval(refresh, intervalMs);
}

function stopLiveRefresh() {
clearInterval(liveInterval);
}

Step 11: Add user data

User data endpoints require a JWT Bearer token from your authentication provider. These endpoints are not public and are scoped to the signed-in user.

async function apiFetchUser(path, token, options = {}) {
return axisApi(path, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...options.headers
}
});
}

Original expanded user-data helper:

async function apiFetch(path, token, options = {}) {
const response = await fetch(`https://{api-host}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...options.headers,
},
});
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
return response.status === 204 ? null : response.json();
}

Bookmarks:

// Get all bookmarks
const bookmarks = await apiFetch('/v0.1/bookmarks?lang=en', token);

// Add a bookmark
await apiFetch(`/v0.1/bookmarks/${itemId}`, token, { method: 'PUT' });

// Remove a bookmark
await apiFetch(`/v0.1/bookmarks/${itemId}`, token, { method: 'DELETE' });

// Check if an item is bookmarked
const isBookmarked = bookmarks.items.some(b => b.itemId === itemId);

Continue Watching:

// Get continue watching list
const continueWatching = await apiFetch('/v0.1/continue-watching?lang=en', token);
// continueWatching.items = items with resume position + next episode suggestions

// Record a watch event (call on play, periodically during playback, and on stop)
await apiFetch('/v0.1/watched', token, {
method: 'POST',
body: JSON.stringify({
itemId,
position: currentPositionSeconds, // current playback position
duration: totalDurationSeconds, // total item duration
event: 'Progress', // 'Start' | 'Progress' | 'End'
}),
});

// Remove an item from continue watching
await apiFetch(`/v0.1/continue-watching/${itemId}`, token, { method: 'DELETE' });

Load user state after sign-in:

const [bookmarks, continueWatching, ratings] = await Promise.all([
apiFetchUser('/bookmarks?lang=en', token).catch(() => null),
apiFetchUser('/continue-watching?lang=en', token).catch(() => null),
apiFetchUser('/ratings?lang=en', token).catch(() => null)
]);

Original expanded version:

async function loadUserState(token, lang = 'en') {
const [bookmarks, continueWatching, ratings] = await Promise.all([
apiFetch(`/v0.1/bookmarks?lang=${lang}`, token).catch(() => null),
apiFetch(`/v0.1/continue-watching?lang=${lang}`, token).catch(() => null),
apiFetch(`/v0.1/ratings?lang=${lang}`, token).catch(() => null),
]);

return { bookmarks, continueWatching, ratings };
}

Step 12: Render images

Items and lists return image URLs in an images dictionary. Use returned image URLs directly where possible.

const poster = item.images?.poster ?? item.images?.thumbnail;

These URLs already point to axis-svc-shain. If you need custom transformations, use Shain transformation options or extract the image ID from an existing Shain URL. Shain URL format:

https://{shain-host}/v1/dataservice/ResizeImage/$value?Format='{format}'&Quality=45&EntityType='Item'&EntityId='{entityId}'&Width=720&Height=406&ImageId='{imageId}'&ResizeAction='fill'&HorizontalAlignment='center'&VerticalAlignment='top'

Where:

  • {imageId} is the GUID ID of the source image stored in the platform, obtained from catalog API responses.
  • {format} is the target image format, such as jpg, webp, or png.
  • {entityId} is the GUID ID of the source entity stored in the platform, obtained from catalog API responses.

Use srcset for responsive images when different viewport sizes need different image widths.

function shainSrcSet(imageId, widths = [320, 640, 1280], format = 'webp') {
return widths
.map(w => `${shainUrl(imageId, `${w}-cr`, format)} ${w}w`)
.join(', ');
}

// In your HTML template:
function renderPoster(imageId) {
return `
<img
src="${shainUrl(imageId, '640-cr', 'jpg')}"
srcset="${shainSrcSet(imageId, [320, 640, 1280])}"
sizes="(max-width: 640px) 320px, (max-width: 1280px) 640px, 1280px"
loading="lazy"
alt=""
/>
`;
}

Extracting image IDs from catalog responses:

/**
* Extract the image ID from an existing Shain URL so you can rebuild it
* with different transformation options.
*
* Shain URL format: https://host/v1/img/{id}={options}.{ext}
*/
function extractImageId(shainUrl) {
const match = shainUrl.match(/\/v1\/img\/([^=]+)=/);
return match?.[1] ?? null;
}

const posterUrl = item.images?.poster; // e.g. "https://shain-host/v1/img/127168=600.jpg"
const imageId = extractImageId(posterUrl); // "127168"

// Now build a different size
const thumbnailUrl = shainUrl(imageId, '200x300-cr', 'webp');

Step 13: Handle pagination

List and search responses include a paging object. Always use paging.next directly to load the next page. When paging.next is null, there are no more pages. Avoid duplicate requests by tracking loading state while a next page is being fetched.

{
"paging": {
"page": 1,
"size": 20,
"total": 5,
"next": "/v0.1/lists/featured?page=2&page_size=20&lang=en",
"previous": null
}
}

Infinite scroll implementation:

class PaginatedList {
#nextUrl = null;
#loading = false;

constructor(initialResponse) {
this.items = initialResponse.items ?? [];
this.#nextUrl = initialResponse.paging?.next ?? null;
this.hasMore = this.#nextUrl !== null;
}

async loadMore() {
if (!this.#nextUrl || this.#loading) return;
this.#loading = true;
try {
const response = await fetch(`https://{api-host}${this.#nextUrl}`);
const data = await response.json();
this.items = [...this.items, ...(data.items ?? [])];
this.#nextUrl = data.paging?.next ?? null;
this.hasMore = this.#nextUrl !== null;
} finally {
this.#loading = false;
}
}
}

// Usage
const list = new PaginatedList(await fetch('https://{api-host}/v0.1/lists/movies?lang=en').then(r => r.json()));

// On scroll to bottom:
window.addEventListener('scroll', async () => {
const nearBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 200;
if (nearBottom && list.hasMore) {
await list.loadMore();
renderNewItems(list.items);
}
});

Step 14: Handle errors and retries

AXIS error responses share this shape:

{
"code": "ITEM_NOT_FOUND",
"message": "The item with id 'abc123' was not found.",
"details": {}
}

Recommended handling:

HTTP statusWhenFrontend behavior
404Item, list, or page not foundShow a not-found screen; do not retry
400Invalid query parametersFix the request, such as invalid max_rating format
500Server errorShow an error state; retry once after a short delay
Network errorNo responseShow offline or error state; retry with backoff

Reusable fetch wrapper with error handling:

async function axisApi(path, options = {}) {
const url = `https://{api-host}${path}`;
let response;
try {
response = await fetch(url, options);
} catch {
throw new Error('Network error - check your connection and try again.');
}
if (response.status === 404) return null;
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message ?? `Request failed with status ${response.status}`);
}
return response.json();
}

// Usage - returns null on 404, throws on other errors
const item = await axisApi(`/v0.1/items/${id}?lang=en`);
if (!item) {
showNotFound();
return;
}

For newly published content, a 404 may be caused by eventual consistency after CMS publish. If your app receives a link to newly published content, for example via push notification, and gets a 404, retry once or twice with a short delay before showing a not-found screen.

async function fetchWithRetry(path, retries = 2, delayMs = 1500) {
for (let i = 0; i <= retries; i++) {
const result = await axisApi(path);
if (result !== null) return result;
if (i < retries) await new Promise(r => setTimeout(r, delayMs));
}
return null;
}

TypeScript Types

Core types for working with the API in TypeScript. These mirror the API response shapes.

export interface ItemSummary {
id: string;
type: ItemType;
subtype: string;
title?: string;
contextualTitle?: string;
shortDescription?: string;
tagline?: string;
classification?: { code: string; advisoryText: string };
path?: string;
watchPath?: string;
scopes?: string[];
releaseYear?: number;
episodeCount?: number;
availableEpisodeCount?: number;
availableSeasonCount?: number;
seasonNumber?: number;
episodeNumber?: number;
episodeName?: string;
showId?: string;
showTitle?: string;
seasonId?: string;
badge?: string;
badgeBackground?: string;
badgeForeground?: string;
badgeImageKey?: string;
genres?: string[];
sports: string[];
duration?: number;
customId?: string;
eventStartDate?: string;
eventEndDate?: string;
offers: Offer[];
images?: Record<string, string>;
themes: Theme[];
customFields?: Record<string, unknown>;
relationships?: ItemRelationship[];
trailerWatchPaths: string[];
buttonLabel: string;
categories?: string[];
keywords?: string[];
}

export interface ItemDetails extends ItemSummary {
description?: string;
advisoryText: string;
copyright: string;
distributor: string;
customMetadata: { name: string; value: string }[];
genrePaths?: string[];
location?: string;
venue?: string;
eventDate?: string;
credits?: Credit[];
seasons?: ItemList;
episodes?: ItemList;
season?: ItemDetails;
show?: ItemDetails;
totalUserRatings: number;
trailers: ItemSummary[];
extensions?: Record<string, unknown>;
}

export interface ItemList {
requestIndex?: number;
id: string;
title?: string;
description?: string;
shortDescription?: string;
tagline?: string;
path: string;
itemTypes?: string[];
size: number;
items: ItemSummary[] | null;
images?: Record<string, string>;
parameter?: string;
paging: Pagination | null;
customFields?: Record<string, string>;
themes?: Theme[];
}

export interface Pagination {
next: string | null;
previous: string | null;
page: number;
size: number;
total: number;
}

export interface Offer {
id: string;
name: string;
price: number;
ownership?: string;
deliveryType?: string;
availability: string;
}

export interface Theme {
type: string;
colors: { key: string; value: string }[];
}

export interface ItemRelationship {
relationTypeKey: string;
items: ItemSummary[];
}

export interface Credit {
name: string;
role: string;
characterName?: string;
images?: Record<string, string>;
}

export type ItemType =
| 'movie' | 'show' | 'season' | 'episode' | 'program'
| 'link' | 'trailer' | 'channel' | 'customAsset' | 'event'
| 'competition' | 'confederation' | 'stage' | 'persona' | 'team' | 'credit';

React Patterns

useFetch Hook

A simple generic hook for data fetching with loading and error states:

import { useState, useEffect } from 'react';

function useFetch<T>(url: string | null): {
data: T | null;
loading: boolean;
error: string | null;
} {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (!url) return;

let cancelled = false;
setLoading(true);
setError(null);

fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<T>;
})
.then(json => { if (!cancelled) { setData(json); setLoading(false); } })
.catch(err => { if (!cancelled) { setError(err.message); setLoading(false); } });

return () => { cancelled = true; };
}, [url]);

return { data, loading, error };
}

Item Detail Page Component

function ItemDetailPage({ itemId, lang, maxRating }) {
const params = new URLSearchParams({ expand: 'all', lang });
if (maxRating) params.set('max_rating', maxRating);

const { data: item, loading, error } = useFetch(
`https://{api-host}/v0.1/items/${itemId}?${params}`
);

if (loading) return <Spinner />;
if (error) return <ErrorState message={error} />;
if (!item) return <NotFound />;

return (
<article>
<header style={{ backgroundImage: `url(${item.images?.hero})` }}>
<h1>{item.title}</h1>
<p>{item.tagline}</p>
</header>
<p>{item.description}</p>
</article>
);
}

Home Page Carousels

import { ItemList } from './types';

function HomePage({ lang, maxRating, sub }: { lang: string; maxRating?: string; sub?: string }) {
const params = new URLSearchParams({
ids: 'featured|page_size=5,new-releases|page_size=20,trending',
lang,
...(maxRating && { max_rating: maxRating }),
...(sub && { sub }),
});

const { data: lists, loading } = useFetch<ItemList[]>(
`https://{api-host}/v0.1/lists/?${params}`
);

if (loading) return <SkeletonRails count={3} />;

return (
<main>
{lists?.map(list => (
<Carousel key={list.id} list={list} />
))}
</main>
);
}

function Carousel({ list }: { list: ItemList }) {
return (
<section>
<h2>{list.title}</h2>
<div className="scroll-row">
{list.items?.map(item => (
<a key={item.id} href={item.path} className="card">
<img src={item.images?.poster ?? item.images?.thumbnail} alt={item.title ?? ''} />
<span>{item.title}</span>
</a>
))}
</div>
</section>
);
}

Bookmark Toggle Button

import { useState } from 'react';

function BookmarkButton({ itemId, token, initialState }: {
itemId: string;
token: string;
initialState: boolean;
}) {
const [bookmarked, setBookmarked] = useState(initialState);
const [busy, setBusy] = useState(false);

async function toggle() {
if (busy) return;
setBusy(true);

const method = bookmarked ? 'DELETE' : 'PUT';

try {
await fetch(`https://{api-host}/v0.1/bookmarks/${itemId}`, {
method,
headers: { Authorization: `Bearer ${token}` },
});
setBookmarked(!bookmarked);
} catch {
// revert on failure
} finally {
setBusy(false);
}
}

return (
<button onClick={toggle} disabled={busy} aria-pressed={bookmarked}>
{bookmarked ? '★ Saved' : '☆ Save'}
</button>
);
}

Production Checklist

Before go-live, confirm:

  • Correct API host is configured per environment.
  • API version is confirmed for your environment and used consistently.
  • lang, max_rating, device, and sub are passed on requests where the endpoint accepts them.
  • Slug routes resolve correctly.
  • Home-page and CMS-page loading uses list_page_size and max_list_prefetch where page responses include multiple lists.
  • Additional lists are lazy loaded through the list API using only the list IDs needed for the current viewport or interaction.
  • Large hierarchies avoid unnecessary expand=all.
  • Pagination uses paging.next.
  • Search is debounced.
  • User-data APIs use JWT and are loaded after sign-in.
  • User-data failures are handled gracefully.
  • Loading, not-found, server error, and network error states are implemented.
  • Images use returned AXIS/Shain URLs or documented Shain transformations.
  • Live TV refresh behavior is controlled.
  • Post-publish 404 retry behavior is implemented where needed.

Was this page helpful?