Skip to content

Rendering Recommendations: From Content IDs to Story Cards

Audience: Front-end developers integrating Arc XP recommendations into a website or app.

Stack used in examples: TypeScript + React.

What this guide covers

The Recommender API returns a ranked list of recommendations, each carrying a card object with display fields sourced from the underlying content document. There is no separate inflation call: one round trip gives you everything needed to render story cards.

  1. Recommend — call GET /recommend/v1/recommendations to get a ranked list of items, each with a card.
  2. Render — map each card onto a card component.

This guide walks through each step, gives you a runnable TypeScript + React snippet, and shows how to handle empty results and error states gracefully.

Prerequisites

  • A site you can issue requests for (you’ll need its site_id).
  • A way to identify the current viewer (you’ll pass that as user_id — anonymous IDs are fine, as long as they’re stable per visitor).
  • An HTTP client. The examples use fetch; any client is fine.

Step 1: Fetch recommendations

Endpoint

GET https://<your-org>-config-prod.api.arc-cdn.net/recommend/v1/recommendations

Query parameters

NameRequiredDescription
site_idyesThe site you’re requesting recommendations for.
user_idyesStable identifier for the current viewer (logged-in or anonymous).
num_resultsnoHow many recommendations to return. 1–50, default 5.
item_idnoAnchor item for “more like this” use cases.
subscription_tiernoCaller subscription level (e.g. "free", "premium").
device_typenoDevice class (e.g. "mobile", "desktop").

Response shape

{
"recommendations": [
{
"item_id": "ABC123…",
"score": 0.87,
"card": {
"title": "Election results upend mayoral race",
"date": "2026-06-01T12:00:00Z",
"author": "Alice Reporter",
"type": "article",
"is_premium": false,
"categories": ["politics"],
"tags": ["election", "2026"],
"url": "https://example.com/story",
"thumbnail_url": "https://cdn.example.com/story.jpg"
}
},
{ "item_id": "DEF456…", "score": 0.81, "card": null }
],
"attribution": { "exposure_id": "exp-…", "issued_at": "" }
}

score is the post-reranking relevance score (0.0–1.0). Editorial signals — boosts, buries, and pins — have already been applied. You can use score to drive UI affordances (badging the top result, sorting, etc.), but the array order is already the recommended display order.

card is the display payload. Every field on the card is optional, and card itself is null when no matching content document was found (for example, an editor-pinned item whose document has not yet been synced). Cards should degrade gracefully when individual fields are missing — the only safe assumption is that a recommendation always has item_id and score.

TypeScript example

type ItemCard = {
title: string | null;
date: string | null;
author: string | null;
type: "article" | "video" | "podcast" | null;
is_premium: boolean | null;
categories: string[];
tags: string[];
url: string | null;
thumbnail_url: string | null;
};
type ScoredItem = {
item_id: string;
score: number;
card: ItemCard | null;
};
type RecommendationResponse = {
recommendations: ScoredItem[];
};
async function fetchRecommendations(params: {
org: string;
siteId: string;
userId: string;
numResults?: number;
}): Promise<ScoredItem[]> {
const url = new URL(`https://${params.org}-config-prod.api.arc-cdn.net/recommend/v1/recommendations`);
url.searchParams.set("site_id", params.siteId);
url.searchParams.set("user_id", params.userId);
if (params.numResults) {
url.searchParams.set("num_results", String(params.numResults));
}
const response = await fetch(url, {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`Recommendations request failed: ${response.status}`);
}
const body = (await response.json()) as RecommendationResponse;
return body.recommendations;
}

Step 2: Map to card fields

A usable story card needs four fields at minimum:

Card fieldSource fieldNotes
Headlinecard.titleDisplay title for the card.
Imagecard.thumbnail_urlHero/lead image. Use a CDN-resized variant if your design system has one.
URLcard.urlThe link the card navigates to on click.
Bylinecard.authorAuthor display string. Fall back to publication name if absent.

Anything beyond those four (kicker, card.date, section label, premium badge from card.is_premium) is layered on top of this minimum set. Build cards that degrade gracefully when an optional field is missing, and skip rendering when card is null or the required fields are missing.

type StoryCardProps = {
item: ScoredItem;
};
function StoryCard({ item }: StoryCardProps) {
const card = item.card;
if (!card || !card.url || !card.title) {
return null;
}
return (
<a href={card.url} className="story-card">
{card.thumbnail_url ? <img src={card.thumbnail_url} alt="" /> : null}
<h3>{card.title}</h3>
{card.author ? <p className="byline">{card.author}</p> : null}
</a>
);
}

Handling empty results and errors

There are three states to design for. Don’t render a half-broken card grid in any of them.

StateWhen it happensRecommended UI
EmptyThe API returns recommendations: []. Common for new sites, new users, cold-start scenarios.Show a fallback rail (editorial picks, most-read) — never an empty grid.
Network errorThe recommendations request fails (timeout, 5xx, network).Show the same fallback rail. Log the error for observability.
ValidationThe API returns 422. Almost always a caller bug (missing/invalid params).Surface during development. In production, treat as the network-error case.
function RecommendationsRail({ org, siteId, userId }: { org: string; siteId: string; userId: string }) {
const [items, setItems] = useState<ScoredItem[] | null>(null);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const scored = await fetchRecommendations({ org, siteId, userId, numResults: 8 });
if (!cancelled) setItems(scored);
} catch (e) {
if (!cancelled) setError(e as Error);
}
})();
return () => {
cancelled = true;
};
}, [org, siteId, userId]);
if (error) return <EditorialFallbackRail />;
if (items === null) return <RailSkeleton />;
if (items.length === 0) return <EditorialFallbackRail />;
return (
<div className="recommendations-rail">
{items.map((item) => (
<StoryCard key={item.item_id} item={item} />
))}
</div>
);
}

Checklist before shipping

  • You’re passing a stable user_id per visitor (not a per-page-load random value).
  • You’re requesting an appropriate num_results for the surface (don’t fetch 50 to render 4).
  • You have a fallback rail for empty results.
  • You have a fallback rail for network/validation errors.
  • Cards render with only the four required fields, even when optional fields are missing.
  • Recommendations with card === null or missing required fields are skipped (or routed to a placeholder).