> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fourdos.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Render a club's articles

> From a content key to a working page: list articles, render one, and page through the archive

This walkthrough builds a page that lists a club's published articles and
renders one of them, using a publishable content key from the browser. It
assumes the content module is installed and you hold a key with your origin on
its allowlist; see [Authentication](/content/authentication).

Set up the values you need:

```bash theme={null}
export API_BASE_URL=https://api.develop.fourdos.dev
export CONTENT_KEY=ck_live_…
export ORIGIN=https://www.yourclub.example
export PLATFORM_USER_ID=…  # the signed-in viewer, when the key has reader naming enabled
```

## 1. Fetch the list

```bash theme={null}
curl "$API_BASE_URL/v1/content/articles?key=$CONTENT_KEY&limit=1" \
  -H "Origin: $ORIGIN"
```

`200 OK`:

```json theme={null}
{
  "articles": [
    {
      "id": "10000001-0000-7000-8000-000000000000",
      "slug": "match-report",
      "title": "Match report",
      "excerpt": "We won",
      "feature_image_url": null,
      "tags": ["match-report"],
      "author_name": null,
      "published_at": "2026-07-20T10:00:00.000Z",
      "updated_at": "2026-07-21T09:00:00.000Z",
      "format": "html",
      "html": "<p>We won.</p>"
    }
  ],
  "page_info": { "next_cursor": null, "has_more": false }
}
```

If you get a `403` here, the `Origin` you sent is not on the key's allowlist.
Browsers set that header themselves; `curl` does not, which is why the example
sets it by hand.

To evaluate the list for a member, add `user_id` to the URL. The key must have reader naming enabled; an absent, blank or unresolved reader gets the strictest answer for each gated article.

One thing to fix in your head before you render any of this: `html` is the only
field that arrives as HTML, and the only one sanitised for you. `title`,
`excerpt`, `author_name`, `feature_image_url` and the block fields `src`, `alt`
and `caption` are plain text an author typed. Every example below sets them
through the DOM (`textContent`, `img.alt = …`) rather than pasting them into a
template string, because an author writing `Kane's "winner"` in an alt box is
enough to break markup built that way, and an author who means harm can do worse
than break it.

## 2. Fetch one article

The list already contains every article's body, so a news index needs one
request. Fetch a single article when you are rendering its own page, from a
route like `/news/match-report`:

```bash theme={null}
curl "$API_BASE_URL/v1/content/articles/match-report?key=$CONTENT_KEY&user_id=$PLATFORM_USER_ID" \
  -H "Origin: $ORIGIN"
```

The response is the article object on its own, with no `articles` wrapper.

Send `user_id` here too, and send the same one the list carried. This endpoint evaluates the gate the same way the list does, so a call that names no reader gets the strictest answer and a gated article comes back as a `403` for everyone. Leave the parameter out only when nobody is signed in.

## 3. Render the index in a page

Everything below runs in the browser. The key is publishable, so it belongs in
this code.

```html theme={null}
<div id="news"></div>

<script type="module">
  const API = "https://api.develop.fourdos.dev";
  const KEY = "ck_live_…"; // publishable: the origin allowlist is the control
  const READER_USER_ID = null; // Set to the current viewer's platform user id when enabled

  const listUrl = new URL(`${API}/v1/content/articles`);

  listUrl.searchParams.set("key", KEY);
  listUrl.searchParams.set("limit", "10");
  if (READER_USER_ID !== null) listUrl.searchParams.set("user_id", READER_USER_ID);

  const response = await fetch(listUrl);
  if (!response.ok) {
    const { error } = await response.json();
    throw new Error(`${error.code}: ${error.message}`);
  }
  const { articles } = await response.json();

  const news = document.getElementById("news");

  for (const article of articles) {
    const item = document.createElement("article");

    if (article.locked === true) {
      const lock = document.createElement("p");
      lock.className = "lock";
      lock.textContent = `Requires ${article.required_tier} or higher.`;

      if (article.member_tier !== null) {
        lock.textContent += ` You are on ${article.member_tier}, ${article.xp_gap} XP away.`;
      } else {
        lock.textContent += ` ${article.xp_gap} XP away.`;
      }
      if (article.unlocks_at !== null) {
        lock.textContent += ` Opens to everyone on ${new Date(article.unlocks_at).toLocaleDateString()}.`;
      }
      item.append(lock);
      news.append(item);
      continue;
    }

    if (article.feature_image_url !== null) {
      const image = document.createElement("img");
      image.src = article.feature_image_url;
      image.alt = "";
      item.append(image);
    }

    const link = document.createElement("a");
    link.href = `/news/${encodeURIComponent(article.slug)}`;
    link.textContent = article.title;
    const heading = document.createElement("h2");
    heading.append(link);
    item.append(heading);

    const meta = document.createElement("p");
    meta.className = "meta";
    const time = document.createElement("time");
    time.dateTime = article.published_at;
    time.textContent = new Date(article.published_at).toLocaleDateString();
    meta.append(time);
    if (article.author_name !== null) meta.append(` by ${article.author_name}`);
    item.append(meta);

    if (article.excerpt !== null) {
      const excerpt = document.createElement("p");
      excerpt.textContent = article.excerpt;
      item.append(excerpt);
    }

    news.append(item);
  }
</script>
```

The `locked` branch renders the lock from the list response itself. The required tier name, reader's tier name, XP gap and unlock moment are all on that row, so the index needs no second call to fill in the lock.

`excerpt`, `feature_image_url` and `author_name` are each nullable, which is
why every one of them is guarded above.

Setting `textContent` and `image.src` assigns a value to a property; nothing in
it is ever parsed as markup, so there is no escaping to remember and no way for
a stray quote in a title to close an attribute. `append` with a string adds a
text node, so the byline is safe the same way.

## 4. Render an article body

The default `html` format is one string, already rendered and sanitised at
publish, so it is the one field you hand to `innerHTML`. The title beside it is
not, so it goes in as text:

```html theme={null}
<article id="article">
  <h1></h1>
  <div class="body"></div>
</article>

<script type="module">
  const articleUrl = new URL(`${API}/v1/content/articles/${encodeURIComponent(slug)}`);

  articleUrl.searchParams.set("key", KEY);
  if (READER_USER_ID !== null) articleUrl.searchParams.set("user_id", READER_USER_ID);

  const response = await fetch(articleUrl);
  const payload = await response.json();

  if (!response.ok) {
    const { error } = payload;
    if (error.code !== "CONTENT_TIER_LOCKED") {
      throw new Error(`${error.code}: ${error.message}`);
    }
    const lock = document.createElement("p");
    lock.textContent = `Requires ${error.details.requiredTier} or higher. ${error.details.xpGap} XP away.`;

    document.querySelector("#article .body").replaceChildren(lock);
  } else {
    const article = payload;
    document.querySelector("#article h1").textContent = article.title;

    document.querySelector("#article .body").innerHTML = article.html;
  }
</script>
```

The reader goes on this URL as well. Without it the gate resolves no member, and a gated article refuses even for a member who is entitled to read it, so the locked branch would paint a lock over a page that should have opened.

That renders the whole body except a video. A video embed arrives as an empty
`<div data-block="video-embed" data-src="…" data-provider="…">`, never an
`<iframe>`, so a browser shows nothing where it sits. If the club publishes
video, take the block format below, or swap those elements for your own player
after the line that sets `innerHTML`. [Articles](/content/articles) has the
element and the swap.

## 5. Or render your own media components

Ask for `?format=blocks` when you want your own image and video components
instead of our markup. Text blocks still arrive as HTML fragments, so only the
two media arms are yours to write:

```js theme={null}
const blocksUrl = new URL(`${API}/v1/content/articles/${encodeURIComponent(slug)}`);

blocksUrl.searchParams.set("key", KEY);
blocksUrl.searchParams.set("format", "blocks");
if (READER_USER_ID !== null) blocksUrl.searchParams.set("user_id", READER_USER_ID);

const response = await fetch(blocksUrl);
const article = await response.json();
if (!response.ok) {
  // A gated article refuses here the same way it does above; handle
  // CONTENT_TIER_LOCKED before you read article.blocks.
  throw new Error(`${article.error.code}: ${article.error.message}`);
}

const body = document.querySelector("#article .body");
body.replaceChildren();

for (const block of article.blocks) {
  switch (block.type) {
    case "image": {
      if (block.src === null) break;
      const figure = document.createElement("figure");
      const image = document.createElement("img");
      image.src = myCdn(block.src);
      image.alt = block.alt; // plain text, and never parsed as markup here
      image.loading = "lazy";
      figure.append(image);
      if (block.caption !== null) {
        const caption = document.createElement("figcaption");
        caption.textContent = block.caption;
        figure.append(caption);
      }
      body.append(figure);
      break;
    }
    case "video_embed":
      // myPlayer returns an element; build it from block.provider and block.src.
      if (block.src !== null) body.append(myPlayer(block.provider, block.src));
      break;
    case "divider":
      body.append(document.createElement("hr"));
      break;
    default: {
      // Text blocks carry a sanitised fragment; an unknown future type carries none.
      if (typeof block.html !== "string") break;
      const holder = document.createElement("div");
      holder.innerHTML = block.html;
      body.append(...holder.childNodes);
    }
  }
}
```

`block.html` is the only value here that goes anywhere near `innerHTML`, and it
is the only one sanitised at publish. `alt` and `caption` are the author's own
text, which is why they are set as properties instead.

Every block type and its fields are on [Block format](/content/blocks),
including how to read a video id out of `block.src` for your own player.

## 6. Page through the archive

An archive page follows `next_cursor` until `has_more` is `false`. Pass the
cursor back exactly as you received it, and pass the same filters on every
request:

```js theme={null}
async function everyArticle() {
  const all = [];
  let cursor = null;

  do {
    const url = new URL(`${API}/v1/content/articles`);
    url.searchParams.set("key", KEY);
    url.searchParams.set("limit", "50"); // 50 is the maximum
    if (cursor !== null) url.searchParams.set("cursor", cursor);

    const page = await (await fetch(url)).json();
    all.push(...page.articles);
    cursor = page.page_info.has_more ? page.page_info.next_cursor : null;
  } while (cursor !== null);

  return all;
}
```

The loop stops on `has_more`, not on how many articles came back. A page can
hold fewer than `limit` (an article that cannot be served is dropped rather than
failing the page), so stopping on a short page would silently cut the archive
off there.

Each article carries a whole rendered body, so a page of 50 is a large
response. Prefer the default `limit` of 10 for anything a reader waits on, and
save the full walk for a build step.

## 7. A featured article and a tag filter

A homepage usually wants one article pinned to the top and a section index
wants one tag:

```js theme={null}
// Newest first, with the cup final preview hoisted above them
`${API}/v1/content/articles?key=${KEY}&pinned=cup-final-preview`

// Match reports only
`${API}/v1/content/articles?key=${KEY}&tag=match-report`
```

The pinned article is returned in addition to `limit` and is removed from its
natural position, so it appears exactly once. If you page beyond the first
page, pass `pinned` on every request or it appears a second time. A `pinned`
slug that names nothing published is ignored and the page is served normally,
so a homepage does not break when the featured article is unpublished.

## 8. Report the read

The page now renders an article. One more call tells the platform that a member
read it, which raises that member's affinity for the tags the article carries and
puts the article on the club's analytics screen:

```html theme={null}
<script type="module">
  import { createEngagementClient } from "engagement-sdk";

  const engage = createEngagementClient({
    key: "ek_live_…",  // the tracking key, NOT the content key above
    endpoint: API
  });

  // After the article is on screen, and only when you know who is reading:
  if (viewer.platformUserId) {
    engage.reportInteraction({
      action: "content.read",       // or content.shared, content.liked
      id: article.id,               // the article's id, not its slug
      userId: viewer.platformUserId
    });
  }
</script>
```

Four things about that call:

* **It takes the other key.** Reading articles uses a content key (`ck_live_…`);
  reporting an interaction rides the engagement rail and uses a publishable
  tracking key (`ek_live_…`). Both are publishable, and neither works in the
  other's place.
* **It takes the id, not the slug.** `article.id` is in every response above.
* **It takes no tags, and offers no way to send any.** The server reads them off
  the article, so the affinity a read raises is for the tags your marketer chose
  in the editor.
* **It needs a signed-in reader.** `userId` is the reader's platform user id.
  There is no anonymous path, so a logged-out visitor is not counted. Passing an
  empty string is safe: the call is dropped without a request, and it is dropped
  even when you have called `identify()`, so a read after the reader signs out
  is never filed under the reader who signed in before.

`reportInteraction` never throws into your page and never blocks the render. A
failed report is reported through the client's `onError` callback and nowhere
else, and a read made as the reader leaves goes out with `navigator.sendBeacon`
rather than being lost. A refresh does not inflate anything: a read counts once
per member, per article, per day, and a repeat of a report that is still in
flight (an effect firing on every render, say) is dropped rather than sent.

If reads for one article stay at zero while others climb, check that the article
has tags. An article published without any cannot produce an interaction event at
all, and the call answers `content_untagged`.

The [engagement SDK reference](/sdk/engagement-sdk#content-interactions) has
every refusal reason and the delivery table. Interactions never earn XP.

## Caching

Responses without `user_id` carry `Cache-Control: private, max-age=60`, so an anonymous browser can reuse them for a minute. A request with `user_id` carries `Cache-Control: no-store`, even when the key does not opt in or the reader does not resolve. It is deliberately not `public`: the key is in the URL, and revoking a key has to take effect at once. If you want a shared cache, call these endpoints from your backend or build step and cache the result under your own rules.

## Where to go next

* [Articles](/content/articles) for every parameter and every error response.
* [Block format](/content/blocks) for the `?format=blocks` shape.
* [Engagement SDK](/sdk/engagement-sdk#content-interactions) for reporting reads,
  shares and likes.
