Skip to content

Documentation JavaScript SDK

Choose a guide from the library.

JavaScript SDK

Generate types from your content models, then read published content in any JavaScript or TypeScript application.

Examples use an illustrative schema. Replace placeholders with your own configuration.

Your first typed fetch

Install the local SDK archive, configure your CMS URL/workspace/public credential, generate Models from your authorized schema, then fetch one collection. Use the sections below in that order. Replace illustrative content_item/title identifiers with your actual content type and fields.

The same client works in a build script, on a server or in a browser with intentionally public content. Your application chooses the environment variables, rendering lifecycle and cache. No website framework or example application is required.

Build and install the package

@lemonadecms/client is a local ESM package with no runtime library dependencies. It is not published to npm. Obtain this repository and use the build/pack workflow below; npm pack builds the compiled package first.

Replace /path/to/cms and /path/to/your-app with your local checkout/application paths. The package declares Node 22+; exact qualified runtimes and release gates are documented in docs/sdk-release.md. TypeScript declarations require TypeScript 5+.

For a reviewed local candidate, run npm run release:check and npm run release:candidate from packages/cms-client. The candidate command prints a new directory containing the archive, SHA256SUMS and manifest.json. Check the checksum and install that exact archive. These commands do not run regression tests or publish; package ownership, licensing and registry acceptance remain separate release gates. Existing @cms/client consumers can keep their old archive or use the file-alias migration in docs/sdk-release.md; new consumers use @lemonadecms/client.

Terminal · from the CMS checkout
cd /path/to/cms/packages/cms-client
npm ci
npm test
npm pack

cd /path/to/your-app
npm install /path/to/cms/packages/cms-client/lemonadecms-client-0.1.0.tgz

Configure one shared client

Create cms.js in your application. Replace the URL, workspace UUID and public key. The SDK does not read environment variables itself; pass values from your framework configuration.

The publicKey option accepts an existing pk_ key or a named lmd_pk_ application token created in API access. Named tokens restrict content types, environment and languages, with independent expiry/rotation/revocation. Include referenced types and optional media access; request an explicitly granted locale. Code generation uses the same scoped schema.

For Vite, use VITE_CMS_URL, VITE_CMS_PROJECT_ID and VITE_CMS_PUBLIC_KEY from import.meta.env. Never give a browser bundle a secret key. Keep the content type and field API identifiers aligned with your schema.

cms.js · published client
import { createCMSClient } from '@lemonadecms/client';

export const cms = createCMSClient({
  url: 'https://YOUR_CMS_HOST',
  projectId: 'YOUR_WORKSPACE_UUID',
  publicKey: 'YOUR_PUBLIC_KEY',
  timeoutMs: 10000 // Optional; choose a budget for your application.
});

Environment variables and validation

The SDK does not load .env files or require fixed variable names. Your application maps configuration to url, projectId and publicKey; a trusted server maps secretKey for optional preview. No new CMS server setting is needed to use the client.

A common server mapping uses CMS_URL for the absolute HTTP(S) base address, CMS_PROJECT_ID for the workspace UUID, CMS_PUBLIC_KEY for published reads and optional CMS_SECRET_KEY for private preview. An environment is selected by its credential, not by a new framework setting. Use HTTPS outside local development. Never put a key in the URL.

Construction rejects missing or invalid URLs, workspace UUIDs, credential formats and key kinds before sending a request. It also rejects a non-object configuration safely, without reflecting its value. CMSConfigurationError identifies configuration failures; the CMS independently validates scope, expiry and revocation on every request. A correctly formatted key is not proof of access.

If you choose an optional CMS_TIMEOUT_MS variable, convert it to a number before passing timeoutMs. The SDK accepts only integer milliseconds from 1 to 2147483647. Omit the option to retain the existing no-default-deadline behavior. An empty value or invalid number fails validation; never silently replace it with a working default.

Only public configuration may use VITE_, NEXT_PUBLIC_, PUBLIC_ or NUXT_PUBLIC_ prefixes. Read CMS_SECRET_KEY only in server-owned code. Keep private values out of HTML, serialized state, logs, browser bundles and static output. Do not log process.env; use the safe configuration error category and option name when diagnosing setup.

Server application · explicit configuration mapping
import { createCMSClient } from '@lemonadecms/client';

const cms = createCMSClient({
  url: process.env.CMS_URL,
  projectId: process.env.CMS_PROJECT_ID,
  publicKey: process.env.CMS_PUBLIC_KEY,
  ...(process.env.CMS_TIMEOUT_MS === undefined ? {} : {
    timeoutMs: Number(process.env.CMS_TIMEOUT_MS)
  })
});
// Construction validates values before the first request.
// Your framework owns loading .env and exposing public variables.

Generate types from your content models

Content-type definitions → authorized schema → generated Models → typed createCMSClient. Define your fields in the Studio, generate the model file in your application, and let TypeScript check the calls you write. You do not maintain a second set of interfaces by hand.

The workflow is similar to generating types from a database schema, but Lemonade uses content-type definitions as the source. Entry field values are stored as JSON; inspecting raw database table columns cannot describe your individual content fields. The CLI reads the authorized schema API, with no database connection or database credentials.

Run from the application where the local SDK archive is installed. First load CMS_URL, CMS_PROJECT_ID and CMS_PUBLIC_KEY into that terminal's exported environment using your shell or secret manager. The CLI does not load .env files. The command below names the credential variable; never replace CMS_PUBLIC_KEY after --credential-env with the actual token value.

Replace production with your environment key or UUID. The delivery credential selects the environment; the CLI verifies the requested workspace and environment against the returned schema. It can generate only content types visible to that credential. Use a suitably scoped public read token; no management credential is needed.

Import the generated Models map and pass it to createCMSClient<Models>. TypeScript checks model and field names, selected fields and compatible filter values. References, images and choice values retain their generated types. Generated types are compile-time assistance; they do not validate arbitrary JSON at runtime or grant access.

Fields allow absence and null, including fields currently required for publication: historical snapshots, selected-field projections and incomplete drafts may omit values. Handle those cases when rendering, as the example's fallback does.

1. Terminal · generate from your authorized schema
# CMS_URL, CMS_PROJECT_ID and CMS_PUBLIC_KEY must already be exported.
npx --no-install lemonade types generate \
  --url "$CMS_URL" \
  --workspace "$CMS_PROJECT_ID" \
  --environment production \
  --credential-env CMS_PUBLIC_KEY \
  --out src/cms-models.ts
2. cms.ts · use the generated model map
import { createCMSClient } from '@lemonadecms/client';
import type { Models } from './cms-models.js';

// Node server or build script; your application loads its environment.
function requiredEnv(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing ${name}`);
  return value;
}

export const cms = createCMSClient<Models>({
  url: requiredEnv('CMS_URL'),
  projectId: requiredEnv('CMS_PROJECT_ID'),
  publicKey: requiredEnv('CMS_PUBLIC_KEY')
});
// Replace content_item/title/position with your own API identifiers.
const page = await cms.content('content_item').list({
  select: ['title', 'position'],
  filters: [{field: 'position', op: 'gte', value: 1}]
});
console.log(page.data[0]?.fields.title ?? 'Untitled');
// Unknown model/field names and incompatible filter values fail type checking.

Keep generated types in sync

Regenerate after changing content-type definitions and review the generated diff with your application changes. Do not hand-edit generated files. Each environment can have a different schema; use the matching workspace, environment and credential for the application you are checking.

Add --check to the same command in CI to compare without overwriting. Exit 0 means the generated output matches; exit 3 means it is missing or different. Other failures, such as invalid credentials or an unavailable CMS, must fail the job too. These are commands you can add to your pipeline, not a claim that your CI is already configured.

For reproducible offline generation, export a reviewed schema snapshot, then use --file. Schema snapshots contain definitions and metadata rather than entry values; review what you commit and never include credentials. The generator accepts TypeScript by default, plus --language csharp or --language python. Existing cms-codegen TypeScript commands remain supported.

C# outputs .NET 8+ System.Text.Json transport models; Python outputs Python 3.11+ TypedDict annotations without runtime dependencies. Generated files are data types; the separate local native clients below provide networking. Raw REST metadata uses names such as published_at; the JavaScript SDK converts top-level metadata to names such as publishedAt. The Node 22+ CLI is a development tool, not a runtime requirement for generated C#/Python applications.

CI · check the same authorized schema without writing
npx --no-install lemonade types generate \
  --url "$CMS_URL" \
  --workspace "$CMS_PROJECT_ID" \
  --environment production \
  --credential-env CMS_PUBLIC_KEY \
  --out src/cms-models.ts \
  --check
Terminal · export once and generate offline
npx --no-install lemonade schema pull \
  --url "$CMS_URL" \
  --workspace "$CMS_PROJECT_ID" \
  --environment production \
  --credential-env CMS_PUBLIC_KEY \
  --out schema.json

npx --no-install lemonade types generate --file schema.json --out src/cms-models.ts
npx --no-install lemonade types generate --file schema.json --language csharp --out Models/Content.cs
npx --no-install lemonade types generate --file schema.json --language python --out app/content_models.py

Fetch a collection and one entry

list() returns one page; get(id) returns one entry. Select limits fields, not authorization. Omitting select returns all fields. Get an ID from page.data or a previous collection response; get() returns a 404 error when the entry is unavailable.

Read each entry’s values under fields. The id and publishedAt metadata sit outside fields. Empty collections are data: []; optional and historical values can be absent or null, even when the current schema makes a field required.

Application module · list and get
import { cms } from './cms.js';

const page = await cms.content('content_item').list({
  select: ['title', 'position', 'summary'], limit: 5
});
for (const entry of page.data) console.log(entry.id, entry.fields.title);
if (page.data[0]) {
  const entry = await cms.content('content_item').get(page.data[0].id);
  console.log(entry.fields);
}
Synthetic SDK response · no private data
{
  "data": [
    {
      "id": "11111111-1111-4111-8111-111111111111",
      "fields": {
        "position": 1,
        "title": "Getting started",
        "summary": "Example summary"
      },
      "publishedAt": "2026-10-15T12:00:00Z"
    }
  ],
  "meta": {
    "limit": 5,
    "nextCursor": null,
    "hasNextPage": false,
    "hasPreviousPage": false,
    "previousCursor": null
  }
}

Use readable page numbers

Use list({page: 1, pageSize: 6}) for familiar numbered pagination. page defaults to 1 and pageSize defaults to 25 when this mode is selected; pageSize accepts 1–100. The response adds meta.page and meta.pageSize alongside existing metadata.

Choose either page/pageSize or limit/after/before. Numbered browsing skips at most 10,000 entries; use cursor pagination or lazy pages()/entries() for larger sequential reads. Each page reads current published content, so edits can move results between numbered pages. Exact totals are not calculated.

Application module · numbered pages
import { cms } from './cms.js';

const page = await cms.content('content_item').list({page: 2, pageSize: 6});
console.log(page.meta.page, page.data);
if (page.meta.hasNextPage) {
  const next = await cms.content('content_item').list({page: page.meta.page + 1, pageSize: 6});
  console.log(next.data);
}

Filter, sort, translate and paginate

Collections support up to ten AND filter clauses and 4 KB of filter JSON. eq checks equality (null matches missing values); in accepts 1–100 values. gt/gte/lt/lte are supported for numbers, dates and UTC date/times. Fields and values must match your schema.

Sort by one supported scalar field using asc or desc. Text, choice, color, boolean, number, date and date/time support sorting. Reference/media and Markdown/long-text fields do not. Dates use ISO strings; timestamps must include a UTC offset.

The example selects title/position/summary, filters position >= 1 and sorts by position. REST accepts existing filters/sort JSON query parameters and readable aliases: filter[position][gte]=1&sort=-position&select=title,position. Do not combine filter with filters. RPC uses JSON values; GraphQL exposes typed inputs. Locale examples require Spanish to be enabled and its content published.

Readable filters use filter[FIELD][OP]=VALUE for eq/gt/gte/lt/lte. For in, repeat filter[FIELD][in][]=VALUE. Numbers, true/false and null are JSON literals; ordinary text is unquoted. Quote literal-looking text, such as filter[title][eq]="123", to retain its string type. sort=position is ascending; sort=-position is descending. URLSearchParams handles escaping. The same schema validation, ten-clause and 4 KB limits apply.

Application module · continue the same query
import { cms } from './cms.js';

const options = {
  select: ['title', 'position', 'summary'],
  locale: 'es', limit: 5,
  filters: [{field: 'position', op: 'gte', value: 1}],
  sort: {field: 'position', direction: 'asc'}
};
const first = await cms.content('content_item').list(options);
if (first.meta.nextCursor) {
  const next = await cms.content('content_item').list({
    ...options, after: first.meta.nextCursor
  });
  console.log(next.data);
}

Return to the previous page

The SDK exposes previousCursor, hasNextPage and hasPreviousPage alongside the existing nextCursor and limit. Use before with the previousCursor; omit after. New metadata is optional when reading older CMS installations.

Application module · previous page
import { cms } from './cms.js';

const options = {limit: 5, sort: {field: 'position', direction: 'asc'}};
const first = await cms.content('content_item').list(options);
if (first.meta.nextCursor) {
  const second = await cms.content('content_item').list({...options, after: first.meta.nextCursor});
  if (second.meta.previousCursor) {
    const previous = await cms.content('content_item').list({...options, before: second.meta.previousCursor});
    console.log(previous.data);
  }
}

Read multiple pages lazily

list() reads one page. pages() yields pages; entries() yields individual entries. Both iterators move forward and fetch only when your loop needs more data. Breaking the loop stops further requests. They preserve selection, filters, sort, locale and preview/public mode; an optional after resumes from a saved boundary.

The default budget is 1,000 pages; maxPages accepts 1–10,000. Reaching the budget with more content raises RangeError instead of silently returning an incomplete result. Save the last page’s nextCursor to resume. Repeated cursors raise CMSResponseError. AbortSignal cancellation and request failures propagate without retries.

The iterator holds one page of entries at a time plus bounded cursor history. It does not create a frozen export snapshot: concurrent publishing or changes to sort values can move entries. For user-facing Previous/Next controls use list() with after or before. Older servers can still supply forward pages; backward metadata requires an upgraded CMS.

Application module · lazy pages and entries
import { cms } from './cms.js';

const controller = new AbortController();
for await (const page of cms.content('content_item').pages({
  select: ['title', 'position'], limit: 50, maxPages: 100,
  sort: {field: 'position', direction: 'asc'}, signal: controller.signal
})) {
  console.log(page.data);
  // Save page.meta.nextCursor if you need to resume later.
  // break stops without fetching another page.
}

for await (const entry of cms.content('content_item').entries({
  select: ['title'], limit: 25, maxPages: 100
})) {
  console.log(entry.fields.title);
}

Use JavaScript, React or Vue

The client is framework-independent. Your framework owns state, rendering, loading, errors and caching. Create cms.js using the configuration above, then use the appropriate example below.

The runnable consumers in examples/delivery provide configuration forms, list, Next page and get workflows. From the CMS root, build the SDK; then run npm install, npm run build and npm start in examples/delivery. Open localhost:4173/plain.html, /react.html or /vue.html. They accept public keys only.

Plain JavaScript · app.js in a module script
import { cms } from './cms.js';

const output = document.createElement('pre');
output.textContent = 'Loading…';
document.body.append(output);
try {
  const page = await cms.content('content_item').list({limit: 5});
  output.textContent = page.data.length ? JSON.stringify(page.data, null, 2) : 'No published entries.';
} catch (error) { output.textContent = error.message; }
React · ContentItems.jsx
import { useEffect, useState } from 'react';
import { cms } from './cms.js';

export default function ContentItems() {
  const [items, setItems] = useState([]);
  const [status, setStatus] = useState('Loading…');
  useEffect(() => {
    const controller = new AbortController();
    cms.content('content_item').list({limit: 5, signal: controller.signal})
      .then(page => {
        if (!controller.signal.aborted) {
          setItems(page.data); setStatus(page.data.length ? '' : 'No published entries.');
        }
      })
      .catch(error => { if (!controller.signal.aborted) setStatus(error.message); });
    return () => controller.abort();
  }, []);
  return <section><p role="status">{status}</p><pre>{JSON.stringify(items, null, 2)}</pre></section>;
}
Vue · ContentItems.vue
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { cms } from './cms.js';
const items = ref([]), status = ref('Loading…');
const controller = new AbortController();
onMounted(async () => {
  try {
    const page = await cms.content('content_item').list({limit: 5, signal: controller.signal});
    items.value = page.data; status.value = page.data.length ? '' : 'No published entries.';
  } catch (error) { if (!controller.signal.aborted) status.value = error.message; }
});
onUnmounted(() => controller.abort());
</script>

<template>
  <section><p role="status">{{ status }}</p><pre>{{ JSON.stringify(items, null, 2) }}</pre></section>
</template>

Use Nuxt with SSR

The complete recipe is maintained in examples/nuxt-guide. Install the local SDK tarball there, npm install, then npm run typecheck and npm run dev. Configure the public values below using Nuxt runtime environment variables.

Public reads use useAsyncData: Nuxt fetches during SSR and transfers the payload to the browser. Include workspace, model and locale in cache keys when making them configurable. Do not cache drafts together with public content.

The server-only preview utility requires an authorization callback before constructing a preview client. This SDK helper is not mounted as an HTTP route. The optional website-preview recipes use the separate entry-scoped, one-use grant protocol described in docs/site-preview.md. Integrate it with your application’s verified server session and permission checks; a query parameter or public key is not user authorization.

nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    cmsSecretKey: '',
    cmsPreviewUrl: '',
    cmsStudioOrigin: '',
    public: { cms: { url: '', projectId: '', publicKey: '' } }
  }
});
.env · replace placeholders
NUXT_PUBLIC_CMS_URL=https://YOUR_CMS_HOST
NUXT_PUBLIC_CMS_PROJECT_ID=YOUR_WORKSPACE_UUID
NUXT_PUBLIC_CMS_PUBLIC_KEY=YOUR_PUBLIC_KEY
# Server only; needed only for previews
NUXT_CMS_SECRET_KEY=YOUR_SECRET_KEY
app/app.vue · SSR published content
<script setup lang="ts">
import { createCMSClient } from '@lemonadecms/client';
const config = useRuntimeConfig();
const cms = createCMSClient(config.public.cms);
const { data: page, status, error } = await useAsyncData(
  `cms:${config.public.cms.projectId}:content_item:en:published`,
  () => cms.content('content_item').list({limit: 5, locale: 'en'})
);
</script>
<template>
  <main>
    <h1>Published content</h1>
    <p v-if="status === 'pending'" role="status">Loading…</p>
    <p v-else-if="error" role="alert">Could not load content. Check configuration and publication.</p>
    <p v-else-if="!page?.data.length">No published entries.</p>
    <pre v-else>{{ JSON.stringify(page.data, null, 2) }}</pre>
  </main>
</template>
server/utils/read-preview.ts · requires your authorization
import { createCMSPreviewClient } from '@lemonadecms/client/server';
import { createError, type H3Event } from 'h3';
import { useRuntimeConfig } from '#imports';

export async function readPreview(
  event: H3Event,
  authorize: (event: H3Event) => Promise<boolean>
) {
  // Supply a trusted server function that verifies your user's session/permissions.
  if (await authorize(event) !== true) throw createError({statusCode: 403});
  const config = useRuntimeConfig(event);
  const preview = createCMSPreviewClient({
    url: config.public.cms.url,
    projectId: config.public.cms.projectId,
    secretKey: config.cmsSecretKey
  });
  return preview.content('content_item').list({limit: 5});
}
// No public route is provided. Never use () => true in an actual application.

Connect Next.js or SvelteKit with secure preview

The repository includes runnable examples/next-guide and examples/sveltekit-guide recipes on Node 24. Each has its own pinned dependencies, .env.example and README. Build the local @lemonadecms/client first, install the chosen recipe, fill its ignored server environment, then start it on port 4175 or 4176. Existing CMS/framework applications are not upgraded.

Published pages use the same typed SDK for article lists/details, numbered pagination and language selection. Next.js uses Server Components with request-time rendering; SvelteKit uses server load functions and the request's fetch. Server-only modules own configuration, and page responses are not shared caches. Replace the illustrative model map with generated types for your schema.

For saved-draft preview, an Owner configures the article destination in the matching environment as the website origin plus /lemonade/preview. CMS_PREVIEW_URL must match exactly; CMS_STUDIO_ORIGIN is the browser-visible studio. Keep CMS_SECRET_KEY server-only without NEXT_PUBLIC_, PUBLIC_ or VITE_ prefixes. The existing one-use exchange produces an HttpOnly entry/language-bound session, not an anonymous arbitrary draft proxy.

Both recipes reuse the shared server protocol and safe article renderer. Preview responses remain private/no-store, no-referrer, noindex and studio-frame constrained; expired or revoked access never falls back to published/unguarded drafts. Exit revokes the session. Unsaved editor changes are excluded. Markdown is escaped text; allowed rich text uses the shared safe renderer. These recipes implement entry preview; the Astro journal additionally demonstrates optional impact comparisons. Any website can implement the framework-independent impact contract with its own renderer. Ordinary CMS client reads require no preview destination.

Run task frameworks:check for type/build checks and task frameworks:verify with a local CMS for disposable HTTP/browser/revocation and bundle checks. These are local Node integrations, not edge/static-export/provider or deployment qualification. If embedded cross-site cookies are blocked, open a new tab instead of weakening the origin/session policy.

Repository root · Node 24
npm --prefix packages/cms-client ci
npm --prefix packages/cms-client run build
npm --prefix examples/next-guide ci
npm --prefix examples/sveltekit-guide ci
# Copy/fill the chosen recipe's .env.example as .env; never commit it.
npm --prefix examples/next-guide run dev
# Or: npm --prefix examples/sveltekit-guide run dev

Use Python or .NET

The repository also includes independent local REST packages: packages/lemonade-python (Python 3.11+, synchronous standard-library reads) and packages/lemonade-dotnet (.NET 8+, asynchronous HttpClient reads). They are not published to PyPI or NuGet. Build/install instructions and the full query/error reference are in docs/native-clients.md in the checkout.

Map your own environment configuration to the client options: CMS_URL, CMS_PROJECT_ID and CMS_PUBLIC_KEY are suggested application variable names. Both reject invalid settings before HTTP. The CMS independently enforces credential grants, environment/model/language scope, expiry and revocation. No new CMS server variables or website framework are required.

Generate models with the existing lemonade types generate command and --language python or --language csharp. Use a generated model with the native package's entry/page envelope. Historical, projected and incomplete fields can be absent/null. Python dictionaries retain snake_case metadata; .NET exposes Id, Fields, PublishedAt and Meta.NextCursor. Raw field values and signed media URLs are preserved.

Both support list/get, selection, languages, filters/sort, cursor/numbered pages and lazy bounded forward iteration. They do not add writes, retries, caches or GraphQL/RPC wrappers. Python timeout is optional socket I/O seconds, not a total deadline or async cancellation. .NET Timeout is an optional per-request/body deadline; reads accept CancellationToken. Set a budget appropriate for your application.

Private preview uses Python create_preview_client or .NET LemonadeClient.CreatePreview on a trusted server. Authorize your user before returning drafts; never put secret keys or private responses in public/static output. Registry publishing and deployment remain paused. Native packages need no Node runtime; only optional model generation uses the existing Node CLI.

Python · installed local wheel and generated models.py
import os
from lemonadecms_client import create_client
from models import Model_content_item

cms = create_client(url=os.environ['CMS_URL'],
    project_id=os.environ['CMS_PROJECT_ID'],
    public_key=os.environ['CMS_PUBLIC_KEY'], timeout=10)
page = cms.content('content_item', Model_content_item).list(
    select=['title'], locale='en', limit=25)
for entry in page['data']:
    print(entry['fields'].get('title'))
.NET · installed local package and generated Models.cs
using LemonadeCMS.Client;
using Lemonade.Content;

using var cms = LemonadeClient.CreatePublished(new PublishedOptions {
    Url = Environment.GetEnvironmentVariable("CMS_URL")!,
    ProjectId = Environment.GetEnvironmentVariable("CMS_PROJECT_ID")!,
    PublicKey = Environment.GetEnvironmentVariable("CMS_PUBLIC_KEY")!,
    Timeout = TimeSpan.FromSeconds(10)
});
var page = await cms.Content<Model_content_item>("content_item")
    .ListAsync(new() { Select = ["title"], Locale = "en", Limit = 25 });
foreach (var entry in page.Data) Console.WriteLine(entry.Fields.Field_title);

Read drafts on your server

Use the separate server entry point with a secret key (sk_) or named preview token (lmd_sk_) through secretKey. Authorize the requesting user first in your own application; importing this factory does not authorize your users. The same list/get methods and options work for previews.

createCMSClient rejects secret keys. The server entry point rejects browser imports. Never render or serialize the secret into HTML, logs, client configuration or component props.

Server-owned code · after user authorization
import { createCMSPreviewClient } from '@lemonadecms/client/server';

const preview = createCMSPreviewClient({
  url: process.env.CMS_URL!,
  projectId: process.env.CMS_PROJECT_ID!,
  secretKey: process.env.CMS_SECRET_KEY!
});
const drafts = await preview.content('content_item').list({locale: 'es'});
// Return only to a user your server has authorized to see drafts.

Choose build-time or request-time fetching

For static generation (SSG), run published reads in your build and await completion before writing pages. Use pages() or entries() with an explicit maxPages for larger collections. A failed or incomplete traversal should fail the build instead of silently shipping missing pages. Publishing or unpublishing in the CMS does not rebuild or remove an already generated website; your build trigger and cache invalidation are application responsibilities.

For server rendering (SSR), fetch inside your framework's server request lifecycle. Pass its fetch implementation when appropriate and propagate request cancellation through signal. Keep user-specific data and draft results out of shared caches and browser bundles.

The SDK requests no-store and does not cache or deduplicate. If your application caches published data, separate workspace/environment, credential scope, model, language and query variants, and choose an explicit expiry/invalidation policy. Never cache private previews as public content. Preserve signed media URLs and refresh expiring ones rather than treating them as permanent static addresses.

For browser rendering, use public credentials only, show loading/empty/error states and abort work when the view is no longer needed. REST, GraphQL and JSON-RPC remain independent APIs; the JavaScript SDK currently wraps REST.

Build script · bounded published content
import { cms } from './cms.js';

for await (const entry of cms.content('content_item').entries({
  limit: 25, maxPages: 100
})) {
  // Write your static page using entry.fields.
  // Rendering, output paths and safe HTML handling belong to your build.
}
// Any fetch, validation or maxPages error should fail this build.

Handle errors and cancellation

CMSConfigurationError means invalid URL/workspace/key settings. TypeError can mean invalid query options or a network failure. CMSRequestError includes HTTP status and server code; CMSResponseError means an invalid response envelope.

The SDK does not automatically retry, cache or deduplicate requests. Integrate your application’s data library if needed. Pass AbortController.signal to list/get and abort when a component unmounts; cancellation preserves the signal's reason, normally AbortError.

Set timeoutMs on createCMSClient or createCMSPreviewClient to opt into a request deadline: a positive integer from 1 to 2147483647 milliseconds. There is no default deadline. CMSTimeoutError means that this budget expired while fetching or reading JSON. Each iterator page gets its own deadline; time spent processing a yielded page does not consume another request's budget. Your injected fetch should honor AbortSignal so cancellation can stop the underlying work.

Combining timeoutMs with a caller signal requires native AbortSignal.any; Node 22+ provides it. In a runtime without it, this optional combination fails locally with CMSConfigurationError instead of weakening cancellation. Existing requests without timeoutMs keep their original signal behavior. A timeout cannot preempt synchronous JavaScript or undo work in your consuming application.

Application module · request errors
import { CMSRequestError, CMSTimeoutError } from '@lemonadecms/client';
import { cms } from './cms.js';

const controller = new AbortController();
try {
  const page = await cms.content('content_item').list({signal: controller.signal});
  console.log(page.data);
} catch (error) {
  if (error instanceof CMSTimeoutError) { /* show a timeout and let the user retry */ }
  else if (controller.signal.aborted) { /* caller cancelled */ }
  else if (error instanceof CMSRequestError) {
    console.error(error.status, error.code, error.message);
  } else throw error;
}
// Call controller.abort() from your framework's unmount cleanup.

References, images and publication

Need a hand? Check troubleshooting. Documentation describes the locally verified pilot; production qualification remains in progress.