Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Using @webiny/sdk to read and write CMS data from external applications. Use this skill when the developer is building a Next.js, Vue, Node.js, or any external app that needs to fetch or write content to Webiny, set up the SDK, use the Result pattern, list/get/create/update/publish entries, filter and sort queries, use TypeScript generics for type safety, work with the File Manager, list languages, trigger or monitor background tasks, or create API keys programmatically. Covers read vs preview m
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 155% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 175% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 213% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 165% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 403% | 0% |
The @webiny/sdk package provides a TypeScript interface for external apps (Next.js, Vue, Node.js) to interact with Webiny's Headless CMS and File Manager. Every method returns a Result object (checked with isOk()). Supports listing, getting, creating, updating, publishing, and unpublishing entries with filtering, sorting, pagination, and TypeScript generics for type safety.
bashnpm install @webiny/sdk
Initialize once and reuse:
typescript// lib/webiny.ts import { Webiny } from "@webiny/sdk"; export const webiny = new Webiny({ token: process.env.WEBINY_API_TOKEN!, endpoint: process.env.WEBINY_API_ENDPOINT!, tenant: process.env.WEBINY_API_TENANT || "root" });
token -- API key token generated in Webiny Admin (Settings > API Keys)endpoint -- The base API URL, without a trailing slash. Run yarn webiny info in your Webiny project to find the API URL. For Website Builder projects use NEXT_PUBLIC_WEBSITE_BUILDER_API_HOST.tenant -- Tenant ID, defaults to "root"> IMPORTANT: Never add a trailing slash to endpoint. The SDK appends /graphql to the endpoint internally, so https://xxx.cloudfront.net/ will break all requests.
fields Parameter (Required)Every SDK method requires a fields array that specifies which fields to return. Omitting it will cause a runtime error.
"values.<fieldId>" for content fields: "values.name", "values.price""id", "entryId", "createdOn", "status""values.author.name"typescript// Minimal fields -- just IDs fields: ["id", "entryId"]; // Content fields fields: ["id", "entryId", "values.name", "values.price", "values.description"]; // Nested reference fields fields: ["id", "values.title", "values.author.name", "values.author.email"];
webiny.cms.listEntries and webiny.cms.getEntry accept a preview parameter to control which revisions are returned:
| preview | Returns | Use For | | ----------------- | ------------------------------------ | -------------------------------- | | false (default) | Published entries only | Public-facing apps, SSG | | true | Latest revision (draft or published) | Content preview, editorial tools |
Write operations (createEntry, updateEntryRevision, etc.) are not affected by preview.
Every SDK method returns a Result object -- it never throws:
typescriptconst result = await webiny.cms.listEntries({ modelId: "product", fields: ["id", "values.name"] }); if (result.isOk()) { console.log(result.value.data); // success -- typed data } else { console.error(result.error.message); // failure -- error info }
Pass a type parameter for full type safety on values:
typescriptimport type { CmsEntryData } from "@webiny/sdk"; interface Product { name: string; price: number; sku: string; description: string; category?: CmsEntryData<ProductCategory>; } interface ProductCategory { name: string; slug: string; } const result = await webiny.cms.listEntries<Product>({ modelId: "product", fields: ["id", "entryId", "values.name", "values.price", "values.sku"] }); if (result.isOk()) { // result.value.data is CmsEntryData<Product>[] const products = result.value.data; // products[0].values.name -- fully typed }
Reference fields like category are typed as CmsEntryData<T>, which wraps referenced entries with id, entryId, and values.
typescriptconst result = await webiny.cms.listEntries<Product>({ modelId: "product", fields: ["id", "entryId", "values.name", "values.price"], sort: { "values.name": "asc" }, limit: 10 });
typescriptconst result = await webiny.cms.listEntries<Product>({ modelId: "product", fields: ["id", "entryId", "values.name", "values.price"], where: { "values.price_gte": 100, "values.name_contains": "Pro" }, sort: { "values.price": "desc" } });
| Operator | Description | Example | | -------------- | ------------------ | -------------------------------------------- | | _eq | Equals (default) | "values.status": "active" | | _not | Not equals | "values.status_not": "archived" | | _contains | Contains substring | "values.name_contains": "Pro" | | _startsWith | Starts with | "values.name_startsWith": "Web" | | _gt / _gte | Greater than / >= | "values.price_gte": 100 | | _lt / _lte | Less than / <= | "values.price_lt": 500 | | _in | In array | "values.status_in": ["active", "featured"] |
Sort is a Record<string, "asc" | "desc"> object:
typescriptsort: { "values.name": "asc" } // alphabetical sort: { "values.price": "desc" } // highest price first sort: { "values.createdOn": "desc" } // newest first
Use where with either id (revision ID) or entryId:
typescript// By revision ID const result = await webiny.cms.getEntry<Product>({ modelId: "product", where: { id: "abc123#0001" }, fields: ["id", "entryId", "values.name", "values.price"] }); // By entry ID (gets latest published revision) const result = await webiny.cms.getEntry<Product>({ modelId: "product", where: { entryId: "abc123" }, fields: ["id", "entryId", "values.name"] });
Pass preview: true to listEntries or getEntry to access unpublished/draft content:
typescriptconst result = await webiny.cms.listEntries<Product>({ modelId: "product", fields: ["id", "entryId", "values.name"], preview: true // returns drafts + published });
> CRITICAL: Content fields MUST be wrapped inside a values key in the data object. Passing fields directly (without values) will result in an empty or malformed entry.
typescriptconst result = await webiny.cms.createEntry({ modelId: "contactSubmission", data: { values: { // ← REQUIRED: wrap all content fields in `values` name: "John Doe", email: "john@example.com", message: "Hello from the contact form!" } }, fields: ["id", "entryId"] });
The method is updateEntryRevision, not updateEntry. Use revisionId (the full entryId#revisionNumber, e.g. "abc123#0001"):
typescriptconst result = await webiny.cms.updateEntryRevision({ modelId: "product", revisionId: "abc123#0001", // ← note: revisionId, not id data: { values: { // ← REQUIRED: wrap fields in `values` price: 29.99 } }, fields: ["id", "entryId", "values.price"] });
The methods are publishEntryRevision and unpublishEntryRevision, not publishEntry/unpublishEntry. Both require revisionId and fields:
typescriptawait webiny.cms.publishEntryRevision({ modelId: "product", revisionId: "abc123#0001", fields: ["id", "entryId", "status"] }); await webiny.cms.unpublishEntryRevision({ modelId: "product", revisionId: "abc123#0001", fields: ["id", "entryId", "status"] });
typescriptawait webiny.cms.deleteEntryRevision({ modelId: "product", revisionId: "abc123#0001", fields: [] });
webiny.languages.listLanguages() returns all enabled languages — disabled languages are always filtered out server-side, so no filter parameter is needed.
typescriptimport type { Language } from "@webiny/sdk"; const result = await webiny.languages.listLanguages(); if (result.isOk()) { const languages: Language[] = result.value; // languages[0].code, .name, .direction, .isDefault }
The Language type:
typescriptinterface Language { id: string; code: string; // e.g. "en-US" name: string; // e.g. "English (US)" direction?: "ltr" | "rtl"; isDefault?: boolean; }
typescript// List files const files = await webiny.fileManager.listFiles({ limit: 20, fields: ["id", "key", "name", "size", "type", "src"] }); // Upload a file (returns presigned URL for direct S3 upload) const uploaded = await webiny.fileManager.uploadFile({ file: myFile });
For programmatic access, create API keys as an extension:
typescript// extensions/MyApiKey.ts import { ApiKeyFactory } from "webiny/api/security"; class MyApiKeyImpl implements ApiKeyFactory.Interface { execute(): ApiKeyFactory.Return { return [ { name: "Universal API Key", slug: "universal-key", token: "wat_12345678", permissions: [{ name: "*" }] } ]; } } export default ApiKeyFactory.createImplementation({ implementation: MyApiKeyImpl, dependencies: [] });
Register (YOU MUST include the .ts file extension in the src prop — omitting it will cause a build failure):
tsx<Api.Extension src={"/extensions/MyApiKey.ts"} />
webiny.tasks wraps the Background Tasks GraphQL API. All methods return a Result and never throw.
Returns all registered task definitions — use this to discover valid definition IDs before triggering.
typescriptconst result = await webiny.tasks.listDefinitions(); if (result.isOk()) { // result.value: TaskDefinition[] for (const def of result.value) { console.log(def.id, def.title, def.description); } }
typescriptconst result = await webiny.tasks.listTasks(); if (result.isOk()) { // result.value: TaskRun[] for (const task of result.value) { console.log(task.id, task.taskStatus, task.definitionId); } }
Optionally filter by a specific task run ID:
typescript// All logs const result = await webiny.tasks.listLogs(); // Logs for a specific task run const result = await webiny.tasks.listLogs({ where: { task: "yourTaskRunId" } }); if (result.isOk()) { for (const log of result.value) { for (const item of log.items) { console.log(`[${item.type}] ${item.message}`); } } }
typescriptconst result = await webiny.tasks.triggerTask({ definition: "myTaskDefinitionId", input: { someVariable: "someValue", anotherVariable: 42 } }); if (result.isOk()) { const task = result.value; // TaskRun console.log(task.id, task.taskStatus, task.executionName); }
The task stops at its next safe checkpoint.
typescriptconst result = await webiny.tasks.abortTask({ id: "yourTaskRunId", message: "Stopped by user request" // optional }); if (result.isOk()) { console.log(result.value.taskStatus); // "aborted" }
typescriptimport type { TaskDefinition, TaskRun, TaskLog, TaskLogItem, TaskStatus } from "@webiny/sdk"; type TaskStatus = "pending" | "running" | "completed" | "failed" | "aborted" | "stopped"; interface TaskDefinition { id: string; title: string; description?: string; } interface TaskRun { id: string; definitionId: string; taskStatus: TaskStatus; input?: unknown; output?: unknown; startedOn?: string; finishedOn?: string; executionName?: string; iterations?: number; parentId?: string; }
| Module | Webiny App | What You Can Do | | ---------------------- | ---------------- | --------------------------------------------------------------------- | | webiny.cms | Headless CMS | List, get, create, update, publish, unpublish, delete entry revisions | | webiny.fileManager | File Manager | List, upload, and manage files and folders | | webiny.tenantManager | Multi-tenancy | Create, install, enable, disable tenants | | webiny.languages | Languages | List enabled languages (id, code, name, direction, isDefault) | | webiny.tasks | Background Tasks | Trigger, abort, list task runs, definitions, and logs |
| Mistake | Correct | | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | data: { name: "..." } | data: { values: { name: "..." } } | | updateEntry(...) | updateEntryRevision(...) | | publishEntry(...) | publishEntryRevision(...) | | unpublishEntry(...) | unpublishEntryRevision(...) | | sort: ["values.name_ASC"] | sort: { "values.name": "asc" } | | getEntry({ id: "..." }) | getEntry({ where: { id: "..." } }) | | Omitting fields | Always provide fields: [...] | | Trailing slash in endpoint | Remove trailing slash from endpoint URL | | triggerTask with unknown definition string | Use an ID returned by listDefinitions() — the GQL schema validates it against WebinyBackgroundTaskDefinitionEnum! |
Install: npm install @webiny/sdk
Import: import { Webiny } from "@webiny/sdk";
Type import: import type { CmsEntryData, TaskRun } from "@webiny/sdk";
Initialize: new Webiny({ token, endpoint, tenant })
Result check: result.isOk() -> result.value / result.error.message
API endpoint: yarn webiny info (in your Webiny project) -- NO trailing slash
Preview mode: pass preview: true to listEntries / getEntry
fields required: every CMS method needs a fields: string[] array
values wrapper: createEntry/updateEntryRevision data must use { values: { ... } }
Background tasks: webiny.tasks.triggerTask({ definition, input })
Abort task: webiny.tasks.abortTask({ id, message? })
Filter logs by task: webiny.tasks.listLogs({ where: { task: "id" } })The SDK's error messages describe the _server's_ view of the world, which sometimes hides the real cause. The patterns below have all bitten real projects — when you hit one of these errors, walk through the checklist before assuming the schema or framework is at fault.
"Content model '<modelName>' not found."This error literally means the currently authenticated principal cannot see a model with that ID — not necessarily that the model is missing. There are three things to check, in order:
confirm the modelId matches exactly (case-sensitive — productCategory, not ProductCategory or product-category).
yet deployed (or that's defined as .private(...) instead of .public(...)) will not be visible via the public Read API.
common cause when the model clearly exists. The CMS returns "not found" rather than "forbidden" on purpose, so an attacker can't probe the schema with an unauthorized key. Open Settings → API Keys → _your key_ and verify it has at least:
cms.contentEntry, cms.contentModel, etc.)read permission scopeFix: grant the missing permission to the key, save, and retry. No code change needed.
The Website Builder starter ships with NEXT_PUBLIC_WEBSITE_BUILDER_API_KEY. That key exists for the WB editor itself and, by default, only has Website Builder permissions — it has no access to any Headless CMS models. Reusing it to power webiny.cms.listEntries(...) from a Server Component is the most common cause of "Content model not found" in WB projects, because to the CMS the request looks just like an unauthorized one.
You have two ways to fix this:
Admin (Settings → API Keys) granting only the CMS permissions the public site needs (typically read on the relevant model groups), expose it as a different env var, and pass it into the SDK:
dotenv # .env NEXT_PUBLIC_WEBSITE_BUILDER_API_KEY=... # for the WB editor only WEBINY_CMS_READ_TOKEN=... # for SDK calls from Server Components
typescript // lib/webiny.ts import { Webiny } from "@webiny/sdk";
export const webiny = new Webiny({ token: process.env.WEBINY_CMS_READ_TOKEN!, // ← NOT the WB key endpoint: process.env.NEXT_PUBLIC_WEBSITE_BUILDER_API_HOST!, tenant: process.env.NEXT_PUBLIC_WEBSITE_BUILDER_API_TENANT || "root" });
Keeping read and editor permissions on separate keys is also better for revocation and the principle of least privilege.
API key in Settings → API Keys and add the required Headless CMS permissions to it. This works, but the same token now controls both the editor and public reads, which makes scoping and rotation harder later.
> Heads-up: NEXT_PUBLIC_* env vars are inlined into the client bundle by Next.js. > Anything you put behind that prefix is publicly visible. A read-only CMS token that's > scoped to public content is fine to expose; an editor or write-capable token is not — > for those, use a non-NEXT_PUBLIC_ env var and only read it from Server Components or > Route Handlers.
endpoint has a trailing slash. The SDK appends /graphql itself, so a trailingslash produces a malformed URL and every call fails. Strip it.
tenant is wrong. In multi-tenant setups, the default "root" tenant won't seemodels defined under another tenant. Confirm the tenant ID matches the one that owns the model.
hostnames. The SDK should always point at the Read API (the public CloudFront URL), not the Admin host.
fields parameter omitted. This throws at request time with a different message,but if you see schema-level errors before any data comes back, double-check that every call passes a non-empty fields: [...].
Install: npm install @webiny/sdk
Import: import { Webiny } from "@webiny/sdk";
Type import: import type { CmsEntryData } from "@webiny/sdk";
Initialize: new Webiny({ token, endpoint, tenant })
Result check: result.isOk() -> result.value.data / result.error.message
API endpoint: yarn webiny info (in your Webiny project) -- NO trailing slash
Preview mode: pass preview: true to listEntries / getEntry
fields required: every method needs a fields: string[] array
values wrapper: createEntry/updateEntryRevision data must use { values: { ... } }webiny-api-cms-content-models -- Define the models you query with the SDKwebiny-website-builder -- Use the SDK inside Website Builder components to fetch CMS dataOther measured skills in the registry, with their headline benchmark lift.