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.
| Service | What it does | Sample endpoints |
|---|---|---|
axis-api-catalog | Content catalog: items, item lists, and search. | /v1/items, /v1/lists, /v1/search |
axis-api-display | CMS pages with resolved layout entries and platform navigation config. | /v1/page, /v1/config |
axis-api-linear | Live and scheduled TV: channel grids and now-playing. | /v1/schedules, /v1/schedules/{id} |
axis-api-search | Full-text search across the catalog. | /v1/search/ |
axis-api-uxmeta | User-scoped data: bookmarks, continue watching, follows, ratings. Requires JWT. | /v1/bookmarks, /v1/continue-watching, /v1/watched, /v1/ratings |
axis-svc-shain | Image 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
| Layer | Responsibility |
|---|---|
| App shell | Routing, layout, navigation, and global app setup. |
| API client layer | Calls AXIS APIs and normalizes common request handling. |
| Request context layer | Provides lang, max_rating, device, and sub for requests. |
| User auth layer | Provides the JWT required for user-scoped endpoints. |
| Feature modules | Home, detail, search, live TV, pages/navigation, and user-data features. |
| UI components | Cards, 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 })
});
| Parameter | Example | Description |
|---|---|---|
lang | en-gb, fr-fr | The language to return content in. |
max_rating | auoflc-pg, mpaa-r | Maximum content rating the current user should see. Items above this rating are excluded server-side. |
device | web_browser, ios, android, tv | The device type. Affects which offers are returned. Defaults to web_browser. |
sub | standard, premium | Comma-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:
| Parameter | Purpose |
|---|---|
list_page_size=24 | Loads 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=3 | Loads 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,optedin | Passes the active segmentation context so the page response matches the current audience or app experience. |
sub=Registered | Passes the current subscription or user state context so offers and page content can be filtered correctly. |
text_entry_format=html | Requests 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:
- Fetch the page using
/v1/api/page. - Render the page layout and the prefetched lists.
- Render unloaded lists using their metadata, title, and loading state.
- When a list is about to enter the viewport, request that specific
list through
/v1/api/lists. - Append the loaded list items into the existing page state.
- Use
paging.nextfor 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=allreturns the complete hierarchy, including the show, seasons, and episodes, in a single response.expand=childrenreturns only the immediate child items and is typically more efficient for navigation scenarios.expand=parentreturns the immediate parent item.expand=ancestorscan 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
Step 9: Build Search
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:
channelsdatehourdurationsegments, where useddevice, where usedsub, 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 asjpg,webp, orpng.{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 status | When | Frontend behavior |
|---|---|---|
404 | Item, list, or page not found | Show a not-found screen; do not retry |
400 | Invalid query parameters | Fix the request, such as invalid max_rating format |
500 | Server error | Show an error state; retry once after a short delay |
| Network error | No response | Show 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, andsubare passed on requests where the endpoint accepts them.- Slug routes resolve correctly.
- Home-page and CMS-page loading uses
list_page_sizeandmax_list_prefetchwhere 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
404retry behavior is implemented where needed.