npm.io
0.26.0 • Published 1 week agoCLI

@content-island/api-client

Licence
MIT
Version
0.26.0
Deps
0
Size
101 kB
Vulns
0
Weekly
0

@content-island/api-client

Installation

npm install @content-island/api-client

Authentication

The client is configured with a single accessToken option that is sent on every request as Authorization: Bearer <accessToken>. Content Island supports two kinds of tokens:

  • Read token — grants read-only access to the project. Use it for any get* method (fetching content, listing, sizing, project metadata).
  • Write token — grants both read and write access. Required for any method that creates, updates, or uploads data.

A write token is a strict superset of a read token: it works for everything. A read token only works for the read methods. Calling a write method with a read token returns 403 Forbidden.

import { createClient } from '@content-island/api-client';

// Read-only consumer (e.g. SSG/SSR site fetching content):
const reader = createClient({ accessToken: '<your-read-token>' });

// Authoring tools, ingestion pipelines, admin scripts:
const writer = createClient({ accessToken: '<your-write-token>' });
Token required per method
Method Token required
getProject Read or Write
getContentList Read or Write
getContent Read or Write
getRawContentList Read or Write
getRawContent Read or Write
getContentListSize Read or Write
createContent Write
updateContentFieldValue Write
uploadMedia Write

Examples

Basic Usage
// Your model
interface Post {
  id: string;
  title: string;
  body: string;
  order: number;
  language: 'es' | 'en';
}
import { createClient } from '@content-island/api-client';

const client = createClient({ accessToken: <your-token>});

const postsWithDefaultLanguage = client.getContentList<Post>({ contentType: 'post'}); // Retrieve the list of contents in the project filtered by content type, for example 'post' in the
const englishPosts = client.getContentList<Post>({ contentType: 'post', language: 'en'}); // Get english posts
const spanishPosts = client.getContentList<Post>({ contentType: 'post', language: 'es'}); // Get spanish posts

// Or you can retrieve a content by id
const postById = client.getContent<Post>({ id: 'content-id', language: 'es' }); // Retrieve a content by id
const postBySomeField = client.getContent<Post>({ 'fields.title': 'post-title', language: 'es' }); // Retrieve a content by field value

Snapshot mode

The client can serve content reads from a content snapshot — a single JSON document exported from your project — instead of hitting the network on every request. This is the recommended way to consume content from a static site generator (Astro, Next.js, Gatsby, …): you export the snapshot once at build time and the build reads from it with zero API round-trips.

There are two modes:

  • 'api' (default) — every read is a network request to the B2B API, exactly as before.
  • 'snapshot' — reads are served from a snapshot file loaded from disk, with no network request. The snapshot engine reproduces the live API's filtering, sorting, pagination, language fallback, and related-content resolution, so the results are identical to api mode over the same data.
Configuration
import { createClient } from '@content-island/api-client';

const client = createClient({
  accessToken: '<your-read-token>',
  mode: 'snapshot',
  // snapshotPath omitted — defaults to DEFAULT_SNAPSHOT_PATH ('./content-island-snapshot.json')
});
  • accessToken is required in all modes. (In snapshot mode it still identifies the client; reads do not use the network, but the token is part of the client contract.)
  • mode defaults to 'api'. Set it to 'snapshot' to serve reads from the snapshot.
  • snapshotPath is client-level and independent of mode: it points at the snapshot file. It is optional — when omitted it defaults to DEFAULT_SNAPSHOT_PATH ('./content-island-snapshot.json'), a constant exported from the package and shared with the CLI's --snapshot-path default. An 'api'-mode client may still set snapshotPath (for example, to expose getSnapshotInfo() while reading live).
import { createClient, DEFAULT_SNAPSHOT_PATH } from '@content-island/api-client';

// Zero-config: export to the default location, then read it back with no path.
//   npx content-island export --access-token <your-read-token>
// writes ./content-island-snapshot.json, which this client resolves automatically:
const client = createClient({ accessToken: '<your-read-token>', mode: 'snapshot' });

snapshotPath (and the DEFAULT_SNAPSHOT_PATH default) is cwd-relative — it is resolved against the process working directory at read time. In an SSR/serverless runtime where the cwd is not the snapshot's location, pass an absolute path, e.g. snapshotPath: path.resolve(process.cwd(), 'content-island-snapshot.json').

If a read resolves to 'snapshot' mode on a client created without snapshotPath, it loads DEFAULT_SNAPSHOT_PATH — there is no "snapshotPath required" error. If no readable, valid snapshot exists at the resolved path, the load rejects with an ApiClientError whose message names that path. On a snapshot-mode client that also configures a snapshotLoader, that error additionally states that the loader is not used for the initial load and points at refreshSnapshot() — the extra advice appears exactly when refreshSnapshot() would work, so an 'api'-mode client carrying a loader keeps the plain message.

The initial load always reads that file. Configuring a snapshotLoader does not change it — see Refreshing a snapshot.

Refreshing a snapshot

A snapshot-mode client can pull fresher content at runtime through an optional snapshotLoader — any async function returning snapshot JSON text or an already-parsed ContentSnapshot. The package's own exportSnapshot already resolves a parsed ContentSnapshot, so it is a SnapshotLoader — pulling from Content Island directly needs no infrastructure of your own:

import { createClient, exportSnapshot } from '@content-island/api-client';

const accessToken = '<your-read-token>';

const client = createClient({
  accessToken,
  mode: 'snapshot',
  snapshotLoader: () => exportSnapshot({ accessToken }),
});

The same read token works — the export endpoint derives the project from it, like every other read. Omit exportSnapshot's snapshotPath here: the refresh keeps the pulled snapshot in memory, and writing the file is the export step's job.

The export endpoint is rate limited — 5 requests per 60-second window by default, keyed by project. Every token of a project shares that budget, so it is a property of your project rather than of a single process: refreshSnapshot() on a per-request SSR path, or across a parallel SSG build farm, will exhaust it. Refreshing on a schedule or from a publish webhook stays comfortably inside it.

Exhausting it costs freshness, not availability: the 429 maps to RATE_LIMITED, the loader rejects, and refreshSnapshot() rejects with it — but the active snapshot is untouched, so reads keep serving it once you have caught the error (see below).

If you do need frequent refreshes, publish the snapshot yourself and load it from a CDN — those reads never touch the export budget:

const client = createClient({
  accessToken: '<your-read-token>',
  mode: 'snapshot',
  snapshotLoader: () => fetch('https://cdn.example.com/snapshot.json').then(r => r.text()),
});

The loader is only invoked by refreshSnapshot() — never during createClient, and never on the initial load. Publishing the snapshot file is the consumer's responsibility; the library's job at startup is just to load it.

const { status, meta } = await client.refreshSnapshot();
// status === 'updated'   → adopted; reads now serve the pulled snapshot (meta is its meta)
// status === 'unchanged' → the pulled snapshot was not newer; the current one is retained

refreshSnapshot() validates the pulled snapshot (shape and schemaVersion), checks it against the active one (same projectId and view, newer exportedAt), and swaps it in atomically — a read in flight during a refresh sees either the whole old snapshot or the whole new one, never a mix. Overlapping calls collapse onto a single pull.

It rejects when the pull fails. A loader rejection, invalid JSON, a bad shape, a schemaVersion mismatch and an identity mismatch all throw an ApiClientError, as does misconfiguration (an 'api'-mode client, or a snapshot client with no snapshotLoader). A failed pull never damages what you already have: the active snapshot is untouched, so reads keep serving it as soon as you have caught the error. You have to catch it — see below.

Refreshing in the background, without taking the server down

Awaiting a refresh on the request path costs your users the latency of a network pull, so the natural call site is a background refresh — fire it and let the request carry on. Fire it unattended, though, and a failed pull becomes an unhandled promise rejection, which on Node >= 15 terminates the process: a blip at Content Island takes your SSR server down. Always attach a .catch():

// WRONG — an unhandled rejection when Content Island is unreachable. The process exits.
client.refreshSnapshot();

// CORRECT — the refresh runs in the background; a failure is a log line, nothing more.
void client.refreshSnapshot().catch(error => {
  console.warn('Content snapshot refresh failed; still serving the previous snapshot.', error);
});

void is there to say the promise is deliberately not awaited; the .catch() is what keeps the process alive. If you do await the call instead, wrap it in a try/catch — same requirement, different shape.

If you have no snapshot on disk

Ship the file. Export the snapshot at build time so it is inside the deployed artifact, and everything else follows: the initial load succeeds from disk, the first render paints without waiting for anything, and refreshSnapshot() is purely an update mechanism for later requests.

// package.json — the CLI reads the token from CONTENT_ISLAND_ACCESS_TOKEN,
// so it never appears in the command line or the build log.
{
  "scripts": {
    "build": "content-island export && <your build command>",
  },
}

Set CONTENT_ISLAND_ACCESS_TOKEN in the build environment, or pass --access-token <your-read-token> explicitly. See the CLI for every flag.

The alternative — bootstrapping the active snapshot from the loader at runtime — is a fallback, not a recommendation, because a read does not wait for an in-flight refresh. In an SSR framework the client is usually a module-level singleton while route loaders run per request, independently of your startup code. If a loader reads before the bootstrap has settled, it hits the initial load, finds no file and throws: the first render dies and a reload appears to fix it, because by then the refresh has completed. The file read fails in microseconds while the network pull takes hundreds of milliseconds, so the read reliably loses that race.

Blocking the module graph with a top-level await to close that gap trades the problem for a slower first paint and a server start that now depends on Content Island being reachable. Shipping the file has neither cost.

If you do bootstrap from the loader, refreshSnapshot() must complete before the first read, and it rejects if the pull fails — there is nothing on disk to fall back to:

const client = createClient({ accessToken: '<your-read-token>', mode: 'snapshot', snapshotLoader });

await client.refreshSnapshot(); // rejects if the pull fails: nothing else can serve reads
const posts = await client.getContentList<Post>({ contentType: 'post' });

That first refresh logs one warning, because adopting without a baseline also means the projectId/view identity guard cannot run. It is expected on this route — and it is also how you find out that a snapshot file you did expect to be there is corrupt rather than absent, since the warning names the path.

Working without a token, or without Content Island

Snapshot-mode reads never touch the network. They serve a file parsed into memory, so neither the access token nor Content Island's availability affects them. Both matter only when refreshSnapshot() runs.

That makes the intended development setup work: commit a snapshot to the repository, work offline with no token, and leave your production snapshotLoader and refreshSnapshot() calls in the code. Reads serve the committed file; the refresh fails and is caught, so the same source runs in both places:

const accessToken = process.env.CONTENT_ISLAND_TOKEN ?? ''; // empty in local development

const client = createClient({
  accessToken,
  mode: 'snapshot',
  snapshotLoader: () => exportSnapshot({ accessToken }),
});

await client.getContentList<Post>({ contentType: 'post' }); // reads the committed file

// Offline this rejects on every run, so say so once and carry on — never `.catch(() => {})`,
// which would also hide a genuine failure in production.
void client.refreshSnapshot().catch(error => {
  console.warn('Content snapshot refresh failed; still serving the committed snapshot.', error);
});

The .catch() is what makes that offline setup survivable — an unattended refreshSnapshot() with no token would exit the process on the first tick.

accessToken is required by the Options type even though snapshot reads never read it, so pass '' (or an env fallback) to satisfy TypeScript — there is no runtime validation of it.

Per-query mode override

The five content reads — getContentList, getContent, getRawContentList, getRawContent, and getContentListSize — accept a per-query mode that takes precedence over the client-level mode for that call only:

effective mode = per-query mode ?? client-level mode ?? 'api'
// An api-mode client that occasionally reads from a snapshot:
const client = createClient({
  accessToken: '<your-read-token>',
  snapshotPath: './content-island-snapshot.json',
});

const live = await client.getContentList<Post>({ contentType: 'post' }); // network
const fromSnapshot = await client.getContentList<Post>({
  contentType: 'post',
  mode: 'snapshot', // this call only — served from the snapshot, no network
});
// A snapshot-mode client that occasionally needs fresh data:
const client = createClient({
  accessToken: '<your-read-token>',
  mode: 'snapshot',
  snapshotPath: './content-island-snapshot.json',
});

const fromSnapshot = await client.getContentList<Post>({ contentType: 'post' }); // snapshot
const live = await client.getContentList<Post>({
  contentType: 'post',
  mode: 'api', // this call only — hits the network
});

getProject is routed by the client-level mode only — it takes no query params, so there is no per-query override.

mode is a client-only key: it is never serialized into the request query string in api mode.

The five content reads also accept an optional per-query callback, onRelatedContentMeta, invoked exactly once with the related-content resolution metadata for that call:

await client.getContentList<Post>({
  contentType: 'post',
  includeRelatedContent: 'all',
  onRelatedContentMeta: ({ resolvedDepth, partial }) => {
    // resolvedDepth: how deep the related-content BFS actually resolved
    // partial: true when a depth or budget cap left part of the graph unresolved
    console.log({ resolvedDepth, partial });
  },
});

It works in both modes — sourced from the X-Related-Content-Resolved-Depth / X-Related-Content-Partial response headers in api mode, and from the engine's BFS result in snapshot mode, with identical values for the same data and query. When the callback is omitted, behavior and return shapes are unchanged. Like mode, it is never serialized into the request query string.

Writes are not available in snapshot mode

A snapshot-mode client serves reads only. Every write/management method (createContent, publishContent, updateContentFieldValue, uploadMedia, createModel, updateModel, deleteModel, createEnum, updateEnum, deleteEnum) rejects with an ApiClientError (code SNAPSHOT_MODE) and performs no network call. There is no per-query override for writes — on a snapshot-mode client they always reject. If you need to read from a snapshot and write, create a second, separate api-mode client for the writes:

const reader = createClient({
  accessToken: '<your-read-token>',
  mode: 'snapshot',
  snapshotPath: './content-island-snapshot.json',
});

const writer = createClient({ accessToken: '<your-write-token>' }); // api mode
getSnapshotInfo()

getSnapshotInfo() resolves with the snapshot's meta block — useful for freshness checks (e.g. logging when the snapshot was produced during a build):

const meta = await client.getSnapshotInfo();
// {
//   schemaVersion: 1,
//   exportedAt: '2026-06-12T10:00:00.000Z', // ISO-8601
//   projectId: '...',
//   view: 'published' | 'preview',
// }

It works on any client, regardless of the client-level mode (so an api-mode client can report on the snapshot it has on disk). On a client without snapshotPath, it loads DEFAULT_SNAPSHOT_PATH; if no readable, valid snapshot exists at the resolved path, it rejects with an ApiClientError whose message names that path.

Exporting a snapshot

CLI: content-island export

The package ships a content-island binary that downloads a snapshot and writes it to disk as plain JSON:

npx content-island export --access-token <your-read-token> --snapshot-path ./content-island-snapshot.json

Note: The content-island binary lives inside the @content-island/api-client package. Inside a workflow (e.g. a CI job or an npm script of a project that already depends on @content-island/api-client), npx content-island resolves the binary from the installed package automatically. To run it standalone outside such a context, install the package globally first so the command is available on your PATH:

npm install -g @content-island/api-client
content-island export --access-token <your-read-token>

The flags map 1:1 (kebab-case) onto the matching exportSnapshot option:

Flag exportSnapshot option Type Description Default
--access-token accessToken string Access token (required). Falls back to the CONTENT_ISLAND_ACCESS_TOKEN environment variable.
--snapshot-path snapshotPath string Path to write the snapshot JSON. ./content-island-snapshot.json
--domain domain string Target domain (self-hosted/staging). Respects the client's default-domain resolution. client default
--no-secure-protocol secureProtocol boolean Use HTTP instead of HTTPS (for local/self-hosted http targets). HTTPS (secure)
--secure-protocol secureProtocol boolean Use HTTPS explicitly (redundant — this is already the default). HTTPS (secure)
--api-version apiVersion string API version segment to target. client default

The request is HTTPS by default. Pass --no-secure-protocol to use plain HTTP when targeting a local or self-hosted http endpoint:

# Export from a local http instance (e.g. a dev server on localhost):
npx content-island export \
  --access-token <your-read-token> \
  --domain localhost:8082 \
  --no-secure-protocol \
  --snapshot-path ./content-island-snapshot.json

The exported view is token-driven: a PREVIEW_-prefixed token exports the preview (draft) view, any other token exports the published view. There is no view flag.

On success the CLI prints a summary and exits 0:

Content snapshot exported successfully.
  Output:     ./content-island-snapshot.json
  Size:       1.42 MB
  Exported:   2026-06-12T10:00:00.000Z
  View:       published

If the token is missing, or the request/validation fails, the CLI writes the error to stderr and exits non-zero — and because the write goes through a temp-file-then-rename, no partial or invalid file is left at --snapshot-path.

Programmatic: exportSnapshot

exportSnapshot is the function the CLI is built on; use it directly from a Node script when you need more control:

import { exportSnapshot } from '@content-island/api-client';

const snapshot = await exportSnapshot({
  accessToken: process.env.CONTENT_ISLAND_ACCESS_TOKEN!,
  snapshotPath: './content-island-snapshot.json', // optional — omit to just get the parsed snapshot
  // domain, secureProtocol, apiVersion are also accepted
});

console.log(snapshot.meta.exportedAt, snapshot.contents.length);

It fetches the snapshot, validates its shape and schema version, optionally writes it to snapshotPath (same safe temp-then-rename strategy as the CLI), and resolves with the parsed ContentSnapshot. A non-2xx response (including 429, which maps to code RATE_LIMITED) rejects with the standard ApiClientError.

Size guidance

The snapshot is a single JSON document loaded fully into memory by the snapshot-mode client. The practical limit is around 20 MB of uncompressed JSON on disk. Above that threshold the CLI prints a warning (it still exits 0): snapshots this large are loaded entirely into memory and may approach upstream request/body and timeout limits. If you cross it, consider narrowing the exported content or keeping the snapshot well under the threshold.

Use mode: 'api' in local development (always fresh, no export step) and mode: 'snapshot' in production builds (fast, no per-request network calls). Switch between them with an environment variable so the same code runs in both:

import { createClient } from '@content-island/api-client';

const client = createClient({
  accessToken: process.env.CONTENT_ISLAND_ACCESS_TOKEN!,
  mode: process.env.NODE_ENV === 'production' ? 'snapshot' : 'api',
  snapshotPath: './content-island-snapshot.json',
});

In production builds, generate the snapshot first (e.g. as a build step) and then run the build, which reads from it in snapshot mode.

GitHub Action snippet

This step exports the snapshot with the CLI before building the site:

name: Build site

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 24

      - name: Install dependencies
        run: npm ci

      - name: Export content snapshot
        env:
          CONTENT_ISLAND_ACCESS_TOKEN: ${{ secrets.CONTENT_ISLAND_ACCESS_TOKEN }}
        run: npx content-island export

      - name: Build (snapshot mode)
        env:
          NODE_ENV: production
        run: npm run build

The token is read from CONTENT_ISLAND_ACCESS_TOKEN, so it never appears in the command line or logs. The build then reads from ./content-island-snapshot.json in snapshot mode.

Documentation

For more detailed documentation, please refer to the Content Island API Client documentation.