Authenticate and choose a content type
Replace YOUR_CMS_HOST, YOUR_WORKSPACE_UUID and YOUR_PUBLIC_KEY. These public examples use the illustrative content_item schema from Getting started. Substitute your actual type and field API identifiers; they are case-sensitive.
Send Authorization: Bearer with every external call, including introspection. Studio cookies do not grant delivery access. A public key reads published snapshots; secret-key previews belong on your server.
Fetch a collection
Run the curl example in your terminal or use the fetch example inside a JavaScript module. Neither this documentation page nor its Copy buttons send content requests.
Your actual content type generates both a single-item field and a Collection field. Adapt the example names and selected fields to match your schema; query variables are sent alongside query in the JSON request.
query ReadItems($after: ID, $locale: String) {
content_itemCollection(limit: 5, after: $after, locale: $locale) {
items { id fields { title position publication_date summary related { id fields { title } } image { id url } } }
nextCursor
}
}
curl 'https://YOUR_CMS_HOST/api/v1/projects/YOUR_WORKSPACE_UUID/graphql' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer YOUR_PUBLIC_KEY' \
--data '{
"query": "query ReadItems($after: ID, $locale: String) {\n content_itemCollection(limit: 5, after: $after, locale: $locale) {\n items { id fields { title position publication_date summary related { id fields { title } } image { id url } } }\n nextCursor\n }\n}",
"variables": {
"locale": "en"
}
}'
const url = "https://YOUR_CMS_HOST/api/v1/projects/YOUR_WORKSPACE_UUID/graphql";
const response = await fetch(url, {
method: 'POST',
credentials: 'omit',
headers: {
Authorization: 'Bearer YOUR_PUBLIC_KEY',
'Content-Type': 'application/json'
},
body: JSON.stringify({
"query": "query ReadItems($after: ID, $locale: String) {\n content_itemCollection(limit: 5, after: $after, locale: $locale) {\n items { id fields { title position publication_date summary related { id fields { title } } image { id url } } }\n nextCursor\n }\n}",
"variables": {
"locale": "en"
}
})
});
const body = await response.json();
if (!response.ok) throw new Error(body.error?.message ?? body.error ?? `HTTP ${response.status}`);
if (body.errors?.length) throw new Error(body.errors.map(e => e.message).join('; '));
console.log(body.data);
Fetch one entry
Replace ENTRY_UUID with an ID returned by the collection. The GraphQL request sends it in variables; JSON-RPC sends params.id; REST puts it in the path. Missing or unpublished items return REST 404, GraphQL null, or RPC error -32004.
curl 'https://YOUR_CMS_HOST/api/v1/projects/YOUR_WORKSPACE_UUID/graphql' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer YOUR_PUBLIC_KEY' \
--data '{
"query": "query ReadItem($id: ID!, $locale: String) {\n content_item(id: $id, locale: $locale) {\n id fields { title position publication_date summary related { id fields { title } } image { id url } }\n }\n}",
"variables": {
"id": "ENTRY_UUID",
"locale": "en"
}
}'
const url = "https://YOUR_CMS_HOST/api/v1/projects/YOUR_WORKSPACE_UUID/graphql";
const response = await fetch(url, {
method: 'POST',
credentials: 'omit',
headers: {
Authorization: 'Bearer YOUR_PUBLIC_KEY',
'Content-Type': 'application/json'
},
body: JSON.stringify({
"query": "query ReadItem($id: ID!, $locale: String) {\n content_item(id: $id, locale: $locale) {\n id fields { title position publication_date summary related { id fields { title } } image { id url } }\n }\n}",
"variables": {
"id": "ENTRY_UUID",
"locale": "en"
}
})
});
const body = await response.json();
if (!response.ok) throw new Error(body.error?.message ?? body.error ?? `HTTP ${response.status}`);
if (body.errors?.length) throw new Error(body.errors.map(e => e.message).join('; '));
console.log(body.data);
Understand the response
Collections return data.content_itemCollection.items and nextCursor; item queries return data.content_item or null. GraphQL can report errors with HTTP 200: inspect errors as well as HTTP status. The requested fields determine the response shape.
This response is synthetic. Fields can be absent or null because optional fields, historical entries and incomplete drafts do not guarantee a value. A null next cursor means the last page.
{
"data": {
"content_itemCollection": {
"items": [
{
"id": "11111111-1111-4111-8111-111111111111",
"fields": {
"position": 1,
"title": "Getting started",
"image": null,
"related": null,
"summary": "Example summary",
"publication_date": "2026-10-15"
}
}
],
"nextCursor": null
}
}
}
Filter and sort
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.
curl 'https://YOUR_CMS_HOST/api/v1/projects/YOUR_WORKSPACE_UUID/graphql' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer YOUR_PUBLIC_KEY' \
--data '{
"query": "query FilterItems($after: ID) {\n content_itemCollection(limit: 5, after: $after,\n filters: [{position: {gte: 1}}],\n sort: {field: \"position\", direction: \"asc\"}) {\n items { id fields { title position summary } }\n nextCursor\n }\n}",
"variables": {}
}'
const url = "https://YOUR_CMS_HOST/api/v1/projects/YOUR_WORKSPACE_UUID/graphql";
const response = await fetch(url, {
method: 'POST',
credentials: 'omit',
headers: {
Authorization: 'Bearer YOUR_PUBLIC_KEY',
'Content-Type': 'application/json'
},
body: JSON.stringify({
"query": "query FilterItems($after: ID) {\n content_itemCollection(limit: 5, after: $after,\n filters: [{position: {gte: 1}}],\n sort: {field: \"position\", direction: \"asc\"}) {\n items { id fields { title position summary } }\n nextCursor\n }\n}",
"variables": {}
})
});
const body = await response.json();
if (!response.ok) throw new Error(body.error?.message ?? body.error ?? `HTTP ${response.status}`);
if (body.errors?.length) throw new Error(body.errors.map(e => e.message).join('; '));
console.log(body.data);
Move forward and backward
For numbered reads use page=1&pageSize=6 in REST, {page: 1, pageSize: 6} in RPC params, or collection(page: 1, pageSize: 6) in GraphQL. REST/RPC add meta.page/page_size; GraphQL exposes page/pageSize. Numbered browsing skips at most 10,000 entries, with sizes 1–100 and default 25. Do not combine page/pageSize with limit/after/before. No exact totals are calculated.
Cursor page sizes are 1–100, default 25. Pass next_cursor as after to move forward, or previous_cursor as before to move backward; never send both. REST/RPC return those cursors plus has_next_page and has_previous_page inside meta. GraphQL exposes nextCursor, previousCursor, hasNextPage and hasPreviousPage on each collection. Treat cursors as opaque and let URLSearchParams encode them for REST.
Keep workspace, model, filters, sort, locale and published/preview mode unchanged. New cursors also bind schema revision and expire after one hour; restart without after/before when they expire or the schema changes. Page size and field selection may change. Existing UUID boundaries and issued advanced forward cursors remain accepted for compatibility.
Default reads use UUID ordering, which is not creation time. Sorting uses entry ID ascending to break ties and puts missing values last. Backward traversal retains this display order. Reads fetch one extra row to detect the page boundary; exact totals are not calculated. Pages remain live across concurrent writes, so restart if content moves or a page becomes empty.
query Page($after: ID, $before: ID) {
content_itemCollection(limit: 5, after: $after, before: $before) {
items { id fields { title } }
nextCursor previousCursor hasNextPage hasPreviousPage
}
}
# Variables: {"after": "RETURNED_NEXT_CURSOR"}
# Or backward: {"before": "RETURNED_PREVIOUS_CURSOR"}
# Keep filters, sort and locale identical to the first request.
Choose a language
Set locale to an enabled workspace locale, such as es or pt-BR. Omit it for the workspace default. REST uses a query parameter, GraphQL a root field argument/variable, and RPC params.locale.
Localized text has independently published translations. Other fields stay shared. Missing translated text uses the default-language value only when the workspace enables fallback; otherwise it can be absent/null. Preview fallback uses drafts, not public snapshots. Unpublishing the default entry hides its translations.
// Variables for ReadItems or ReadItem:
{"locale": "es"}
Preview drafts on your server
Replace YOUR_SECRET_KEY only in server-owned code. Authorize your own application’s user before returning draft data. Public keys cannot read previews.
Saving changes does not update a published snapshot until you publish again. Preview requests read current drafts and draft reference targets; published reads use current published snapshots.
curl 'https://YOUR_CMS_HOST/api/v1/projects/YOUR_WORKSPACE_UUID/preview/graphql' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer YOUR_SECRET_KEY' \
--data '{
"query": "query ReadItems($after: ID, $locale: String) {\n content_itemCollection(limit: 5, after: $after, locale: $locale) {\n items { id fields { title position publication_date summary related { id fields { title } } image { id url } } }\n nextCursor\n }\n}",
"variables": {
"locale": "en"
}
}'
References, images and publication
A parent cannot publish until its referenced entries and assets are published. Published dependencies cannot be unpublished while used, and deletion is blocked while draft or published references remain.
REST and RPC expand reference IDs into delivery objects with id/fields; the SDK preserves those nested wire shapes. Expansion is bounded, so keep the target ID when deeper fields are absent. GraphQL traverses references only when selected: related { id fields { title } }.
Image/file delivery includes metadata and a short-lived URL. Keep that URL intact; fetch fresh content to obtain a new URL after expiry. Asset requests recheck availability and key validity. PDFs download rather than display inline. Draft metadata stays private until publication.
Treat Markdown as source, and render it with a renderer that excludes raw HTML. Do not inject arbitrary source into innerHTML.
Use the Explorer and understand limits
Open GraphQL Explorer in the studio, sign in if needed, and choose your workspace. Your current role must allow GraphQL explorer access. Inspect documentation/autocomplete, adapt content_item and its fields, and paste the query. Put id/locale/after/before values in Variables.
Opening the Explorer does not load or execute this guide’s sample, overwrite an existing query, or grant external API access. Choose Published or Preview explicitly and press Run yourself. External introspection requires a bearer key.
Schema changes appear without redeployment. Queries are read-only, with depth 8 and complexity 1,000 including list multipliers, aliases and fragments. Fields are nullable for historical absence and incomplete drafts.
Query text is limited to 65,536 UTF-8 bytes; JSON-encoded variables to 65,536 bytes. Parsing allows 4,096 tokens. Documents allow 512 field selections, 100 aliases and 32 fragment definitions. A query below the byte limit may still exceed another limit. Persisted queries are not currently supported.
Fetch shallow list fields, then selected-item details. Collections allow 1–100 items with a default of 25, but nested fields can require smaller pages. Reference lists conservatively reserve cost for up to 100 related entries and blocks for up to 20 blocks. Fragments and minification do not reduce execution cost.
For SSG, follow nextCursor with bounded build concurrency and stop at hasNextPage=false. Keep query, filters, locale and sort unchanged. Page requests do not share a frozen snapshot, so content published during the build can change later pages. Use variables, inspect HTTP status plus errors, and keep preview secrets on the server. The CMS SDK currently uses REST; call GraphQL with standard HTTP fetch or a GraphQL client.
Fetch what this screen needs
Start with the fields visible on the current screen. A card grid might need a title, position and image URL; fetch summary text and related content when someone opens an item. Fewer selected fields reduce response size and avoid unselected resolver work, but do not guarantee that the database reads only those JSON fields.
The broad example below requests 100 entries and detail relationships together. It exceeds the current complexity budget on the illustrative schema, even with very little stored content: cost is estimated before execution. The card query and single-entry detail query fit independently. Adapt the identifiers to your own schema and measure the tradeoff between request count, response size and waiting time.
Use named operations and variables for IDs, cursors and locales. Fragments help share field selections across your code; they do not make the expanded selection cheaper. Minifying whitespace can reduce document bytes, not relationship traversal or query cost.
query EverythingForEveryScreen {
content_itemCollection(limit: 100) {
items {
id
fields {
title position publication_date summary
related { id fields { title summary image { id url } } }
image { id url contentType byteSize }
}
}
nextCursor
}
}
query ContentCards($after: ID, $locale: String) {
content_itemCollection(limit: 12, after: $after, locale: $locale) {
items { id fields { title position image { url } } }
nextCursor
hasNextPage
}
}
query ReadItem($id: ID!, $locale: String) {
content_item(id: $id, locale: $locale) {
id fields { title position publication_date summary related { id fields { title } } image { id url } }
}
}
Filter on the server and page through results
Send supported typed filters and sorting to Lemonade rather than fetching a collection just to discard most of it in the browser. Filtering can reduce returned data; its database cost still depends on the predicate and data. Do not assume a text search or custom sort is free or automatically indexed.
Start with a modest page size that fits your screen. ContentCards uses 12 as an example, not a measured optimum. Follow nextCursor as after with the same workspace, environment credential, mode, locale, filters and sort; stop at hasNextPage=false. Treat cursors as opaque. Numbered pagination is available for browsing but has a 10,000-row offset window; use cursors for longer traversals.
Smaller outer pages matter especially with reference lists, rich text and blocks. Lemonade currently estimates reference lists at up to 100 children and block lists at up to 20. A tiny stored list can still have a high estimated cost. Keep room below the 1,000 budget; 100 is a maximum collection size, not a recommended size for every selection.
Do not turn a pagination loop into many simultaneous requests: each cursor depends on its preceding response. Fetch that chain sequentially, detect a missing/repeated cursor, and stop or report an incomplete build rather than looping forever. New cursors expire after one hour and schema changes can invalidate them; restart the traversal when necessary.
query FilterItems($after: ID) {
content_itemCollection(limit: 5, after: $after,
filters: [{position: {gte: 1}}],
sort: {field: "position", direction: "asc"}) {
items { id fields { title position summary } }
nextCursor
}
}
Deduplicate work and cancel obsolete requests
Reuse data already loaded for the current screen and deduplicate identical in-flight requests within the same authorized render/navigation. Include the operation, variables, workspace/environment, publication mode and credential scope in the identity. Never use only the GraphQL URL as a data-cache key or share a pending preview request across users.
Debounce rapidly changing search input, fetch detail content when it is needed, and cancel obsolete requests with AbortController. Also discard late results when the search, locale, workspace or current screen has changed. Cancellation prevents stale UI work; it does not guarantee that the server stopped processing a request already received.
Lemonade uses request-local DataLoader batching for selected entry references. This is different from HTTP request batching: sending an array of GraphQL operations is not supported. Avoid replacing one bounded relationship query with a separate request for every card unless measurements justify that choice.
Use the documented POST endpoint. GraphQL GET queries, persisted queries/APQ, multipart @defer/@stream delivery and subscriptions are not current performance options in Lemonade. The CMS JavaScript SDK uses REST; use standard fetch or a GraphQL client configured for these supported HTTP contracts.
Understand caching and freshness
Lemonade currently sends Cache-Control: no-store on delivery responses. Respect it: do not enable a browser, service-worker or CDN cache that stores bearer-authenticated responses despite that header. Keep preview credentials and draft data on authorized server paths; never put them in shared caches or generated public pages.
There are separate internal optimizations. Compiled GraphQL schemas are cached by schema identity; this does not cache query results. Eligible legacy-key published content reads can use the internal generation-invalidated read cache after authorization. Preview, permission-scoped Studio and scoped application-token reads bypass that shared read cache. Request-local reference batching is another separate layer.
Internal invalidation does not refresh an already generated website, your application's stored state or an external search index. Use publishing webhooks to trigger the consuming application's appropriate rebuild/revalidation workflow. Verify webhook authenticity, handle retries idempotently and preserve current authorization. A content fetch during SSG is not a promise of automatic rebuilds.
Choose a fetching strategy for your application
SSG/build time: walk the required cursor pages and languages, then fetch only required detail data. Start with a small bounded worker pool for independent detail requests, such as two workers, and measure before raising it. Do not call Promise.all over every content ID. Cursor traversal itself stays sequential.
SSR/request time: fetch only what the current response needs. Independent reads may run concurrently within a small bound; avoid serial waterfalls where one fetch does not depend on another. Keep credentials on the server where required and handle optional content failures separately from content required to render the page.
Browser/client rendering: show useful loading and error states, load the first page promptly, and fetch more on demand. Use images sized for their display area and Lemonade's available responsive renditions; fetching a smaller JSON response will not fix an oversized image download.
Multi-page reads are live, not a frozen snapshot. Publishing during a build can change later pages. Keep builds bounded and choose a consistency/rebuild policy for your application. For failures, inspect GraphQL errors as well as HTTP status; retry only understood transient failures, with bounded attempts/backoff and Retry-After when present. Do not retry invalid queries, denied access or expired cursors unchanged.
Measure before changing the query
Compare the same operation, variables, dataset, publication mode and network conditions. Record the first request separately from repeated requests: schema compilation, connection setup and eligible read-cache warming can affect them. Try several runs, report the median and range, and change one variable at a time. A local measurement is not a production capacity claim.
In browser Network tools, distinguish time to first byte, response download and total request time. Inspect payload size and duplicate requests. Use the Performance panel to separate JSON handling, rendering and image loading from API time. The helper below measures fetch plus JSON parsing; decoded JSON bytes are not compressed transfer bytes, and neither number measures screen rendering.
If the API wait dominates, try a smaller page and remove heavy relationships or remote-source fields one at a time. If download dominates, trim the selection and review actual transport compression with the operator. If rendering dominates, page/virtualize your own UI and resize images. Do not add a browser Accept-Encoding header: browsers manage it, and compression depends on the serving deployment.
Use a sanitized operation label when recording metrics. Do not log bearer keys, variables, complete response bodies, draft content or tenant identifiers. GraphQL errors and partial data are not successful performance samples. This helper logs aggregate diagnostics locally only; it installs no monitoring or cache.
// Replace the host, workspace and public credential. Keep preview secrets server-side.
// Pass the ContentCards operation above as query, with { locale: 'en' } initially.
export async function measureContentCards(query, variables, signal) {
const started = performance.now();
const response = await fetch(
'https://YOUR_CMS_HOST/api/v1/projects/YOUR_WORKSPACE_UUID/graphql',
{
method: 'POST',
credentials: 'omit',
cache: 'no-store',
headers: {
Authorization: 'Bearer YOUR_PUBLIC_KEY',
'Content-Type': 'application/json'
},
body: JSON.stringify({ query, operationName: 'ContentCards', variables }),
signal
}
);
const body = await response.json();
const elapsedMs = performance.now() - started;
if (!response.ok || body.errors?.length || !body.data?.content_itemCollection) {
// Handle the failure without logging credentials or private response bodies.
throw new Error('ContentCards failed; inspect HTTP status and GraphQL errors safely.');
}
console.table([{
operation: 'ContentCards',
elapsedMs: Number(elapsedMs.toFixed(1)),
decodedJsonBytes: new TextEncoder().encode(JSON.stringify(body)).byteLength,
items: body.data.content_itemCollection.items.length
}]);
return body.data.content_itemCollection;
}
// Pass an AbortController.signal; abort on navigation and reject late context changes.
// For later pages, pass nextCursor as after with the same locale and query options.
Handle failures
Always check HTTP status and parse the response body. REST returns error/code. GraphQL returns an errors array; JSON-RPC returns an error object with code/message. The fetch examples above handle both HTTP and protocol errors.
401: check the workspace and bearer key. 403: previews require a secret key. Invalid fields/locales/queries/cursors require changing the request, not retrying it unchanged. Avoid logging credentials or private response bodies.