@content-island/api-client
@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')
});
accessTokenis 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.)modedefaults to'api'. Set it to'snapshot'to serve reads from the snapshot.snapshotPathis client-level and independent ofmode: it points at the snapshot file. It is optional — when omitted it defaults toDEFAULT_SNAPSHOT_PATH('./content-island-snapshot.json'), a constant exported from the package and shared with the CLI's--snapshot-pathdefault. An'api'-mode client may still setsnapshotPath(for example, to exposegetSnapshotInfo()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.
Related-content metadata: onRelatedContentMeta
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-islandbinary lives inside the@content-island/api-clientpackage. 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-islandresolves 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 yourPATH: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.
Recommended workflow
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.