Developing with @craftware/crafty-sdk
Use this when writing application code that reads published Crafty content (pages/blocks, entries, collections, media) or submits forms. Authoring/configuration is done via the MCP server (see mcp-configuration.md), not the SDK.
Install
npm install @craftware/crafty-sdk
Requires Node >=20, or any runtime with fetch (browser, Bun, Deno, modern edge runtimes).
Create a client
import { createCraftyClient } from "@craftware/crafty-sdk";
const crafty = createCraftyClient({
baseUrl: "https://<your-tenant>.crafty.do",
apiKey: process.env.CRAFTY_API_KEY,
siteSlug: "main-site",
timeoutMs: 10_000,
});
createCraftyClient(config) returns a plain object with six resources (collections, entries, pages, forms, mediaCollections, mediaFiles), the image helpers image and srcset, and the visual editor helpers attrs, attrsHtml and previewFromRequest.
Config options
| Option | Required | Notes |
|---|---|---|
baseUrl |
yes | Tenant base URL. Trailing slashes are stripped; /api/v1 is appended automatically unless already present. |
apiKey |
no | Environment API token. Sent as X-API-Key. Resolves the site + environment server-side, so pages.* need no siteSlug. |
siteSlug |
no | Site slug. Required for forms.submit() (the public submit path is /sites/{siteSlug}/forms/{formSlug}/submit). |
fetch |
no | Custom fetch. Defaults to globalThis.fetch. If none is available, construction throws CraftyError (NETWORK_ERROR). |
headers |
no | Default headers merged into every request. |
timeoutMs |
no | Default per-request timeout. A timeout is only armed when the value is a number > 0. |
preview |
no | Visual editor preview token sent as X-Crafty-Preview. Omit in the browser (read from ?crafty_preview=, kept in sessionStorage); pass previewFromRequest(req) on the server; false never sends one. |
onPreviewInvalid |
no | Called when a request that carried a token gets X-Crafty-Preview-Status: invalid (the response is the public content). |
Per-call options
Every method takes an optional final options argument:
signal: AbortSignal— merged with the internal timeout signal.headers: HeadersInit— merged over the client's default headers.timeoutMs: number— overrides the client timeout for this call.
const posts = await crafty.entries.list(
"posts",
{ per_page: 15, page: 1, sort_by: "updated_at", sort_order: "desc" },
{ timeoutMs: 5_000 }
);
Resources
collections
crafty.collections.list(options?) // => { data: CraftyCollection[] }
crafty.collections.get(slug, options?) // => CraftyCollection (BARE object, not wrapped)
entries
crafty.entries.list(collectionSlug, query?, options?) // => { data: CraftyEntry[], links, meta } (paginated)
crafty.entries.get(collectionSlug, id, options?) // => { data: CraftyEntry }
query: q (text search over the collection's text fields), filter (field-value conditions, e.g. { precio: { gte: 150000, lte: 300000 }, tipo: "apartamento", sector: { in: ["piantini", "naco"] } }; operators per field type in the content-modeling guide), sort_by, sort_order (asc/desc), per_page, page.
pages
crafty.pages.list(query?, options?) // => { data: CraftyPage[], links, meta } (paginated)
crafty.pages.get(pageSlug, options?) // => enriched page (see below)
Pages are scoped server-side by the site the apiKey resolves to — there is no siteSlug argument. query accepts per_page and page.
pages.get() resolves to { data: CraftyPage } plus block helpers:
| Helper | Returns |
|---|---|
page.blocks |
CraftyBlock[] (all blocks; non-array data coerced to []) |
page.block(type) |
first block of type, or null |
page.blocksOf(type) |
all blocks of type ([] if none) |
page.hasBlock(type) |
boolean |
page.requireBlock(type) |
first block, or throws a plain Error (Crafty page block not found: …) — not a CraftyError |
page.byType |
Record<type, CraftyBlock[]> |
page.firstByType |
Record<type, CraftyBlock | null> |
page.data |
the raw CraftyPage |
const landing = await crafty.pages.get("landing");
const hero = landing.block("hero"); // CraftyBlock | null
const features = landing.blocksOf("feature"); // CraftyBlock[]
const cta = landing.requireBlock("cta"); // throws plain Error if absent
Block data shapes (block types)
block.data is keyed by the block type's field keys. Field values by type:
| Field type | block.data[key] |
|---|---|
string, text, url |
string |
longText |
sanitized HTML string |
number |
integer |
boolean |
boolean |
stringList |
string[] (e.g. ["AI", "Cloud"]) |
repeater |
array of objects; every declared subfield is present, null when empty (e.g. [{ icon, title, text, tags: string[] }]) |
reference |
collection reference: entry summary { id, slug, label } or null; media reference (model: "Media"): the media id, plus <key>_media with { id, url, file_name, mime_type, size, is_public, image_transform_base } or null |
multiReference |
collection: array of entries; media (model: "Media"): array of media ids, plus <key>_media with the files in the same order (missing files are skipped) |
Entries expose media references the same way (entry.image is the id, entry.image_media.url the file).
const solutions = home.block("solutions"); // block type slug "solutions"
const items = solutions?.data.items ?? [];
items.map((item) => `${item.title}: ${item.tags.join(", ")}`);
const hero = home.block("hero");
const heroUrl = crafty.image(hero?.data.image_media, { width: 1600, format: "auto" }); // reference, model "Media"
const slides = hero?.data.gallery_media ?? []; // multiReference, model "Media"
Images: always go through crafty.image()
Render every Crafty image with crafty.image(media, { width, format: "auto" }) (and crafty.srcset(media, widths, { format: "auto" }) for responsive images) instead of the raw url. Sites that ship the original upload fail Core Web Vitals (LCP).
const photo = block.data.image_media;
<img
src={crafty.image(photo, { width: 800, format: "auto" })}
srcSet={crafty.srcset(photo, [400, 800, 1200, 1600], { format: "auto" })}
sizes="(min-width: 768px) 50vw, 100vw"
width={800}
height={450}
loading="lazy"
alt={block.data.title ?? ""}
/>
crafty.image(media, { width?, height?, fit?, quality?, format? })→ string.mediais a<key>_mediaobject, aCraftyMediaFile, or a URL string.width/height1–4096,quality1–100,fit:scale-down|contain|cover|crop|pad,format:auto|webp|avif|jpeg|png. Invalid options are dropped.crafty.srcset(media, widths, options?)→"<url> 400w, <url> 800w", or""when the image cannot be transformed.- Pick
widthfrom the rendered size (about 2x for retina on small images); usefit: "cover"with bothwidthandheightfor fixed-ratio cards and thumbnails. - Transforms only apply to public raster images when the Crafty server has Cloudflare Image Transformations enabled (the media then carries
image_transform_base). Otherwiseimage()returns the originalurlandsrcset()returns"", so the same code always works. Upload images to a public repository (manage_media_collectionwithis_public: true) when the site needs permanent, resizable URLs. - Use
og:imagewithcrafty.image(media, { width: 1200, height: 630, fit: "cover", format: "jpeg" }): social scrapers do not support AVIF/WebP negotiation. - Do not hand-build
/cdn-cgi/image/...URLs or append?w=query parameters.
Prefer modelling lists as repeater / stringList fields on a block type over collections or json fields; see the crafty-cms skill.
Generated types (crafty types)
Do not guess field keys or shapes: generate them from the site's real content models with the CLI.
npx @craftware/crafty-cli types # CRAFTY_API_URL + CRAFTY_API_KEY from .env
npx @craftware/crafty-cli types --api-key=$KEY --url=https://<tenant>.crafty.do --out=src/crafty.types.ts
It reads GET /api/v1/schema (JSON Schema of every collection and block type of the API key's environment, versioned by format_version) and writes crafty.types.ts:
<Slug>Entryper collection (entry metadata + fields),<Slug>BlockDataand<Slug>Blockper block type, theCraftyPageBlockunion (discriminated bytype), theCraftyCollectionTypes/CraftyBlockDataTypesslug maps, andCraftyEntryReference/CraftyMediaSummary.select→ union of option values,stringList→string[],repeater→ array of item objects,json→unknown; optional fields are| null.
import type { CarsEntry, CraftyPageBlock } from "./crafty.types";
const cars = (await crafty.entries.list("cars")).data as unknown as CarsEntry[];
const page = await crafty.pages.get("home");
for (const block of page.blocks as unknown as CraftyPageBlock[]) {
if (block.type === "hero") block.data.heading; // HeroBlockData
}
Types depend on the environment (generate with the key of the environment the code targets) and go stale when a collection or block type changes — re-run the command after create_block_type, update_block_type, create_collection or update_collection.
forms
crafty.forms.list(options?) // => { data: CraftyForm[] } (requires apiKey)
crafty.forms.get(slug, options?) // => { data: CraftyForm } (requires apiKey)
crafty.forms.submit(formSlug, data, options?) // => { id, form_id, submitted_at } (public; apiKey optional, picks the environment)
crafty.forms.captcha(form, container) // => CraftyFormCaptcha | null (browser; renders the form's captcha widget)
forms.submit() uses a public endpoint: the X-API-Key, when configured, only selects the environment (a staging key submits to staging, no key submits to production), but it requires siteSlug in the client config — calling it without one throws a synchronous CraftyError with code BAD_REQUEST.
const crafty = createCraftyClient({
baseUrl: "https://<your-tenant>.crafty.do",
siteSlug: "main-site", // required for submit()
});
const result = await crafty.forms.submit("contact", {
name: "Jane Doe",
email: "[email protected]",
message: "Hello!",
});
// => { id, form_id, submitted_at }
Forms protected by a captcha return captcha: { provider, site_key, token_field } (absent otherwise), and their submissions are rejected with 422 without the widget token. In the browser, render the widget next to the form and pass it to submit():
const { data: form } = await crafty.forms.get("contact");
const captcha = await crafty.forms.captcha(form, document.querySelector("#captcha")!); // null when not needed
await crafty.forms.submit("contact", values, { captcha }); // adds the token, then resets the widget
The provider is set by Crafty, so read it from captcha.provider instead of assuming one: Turnstile runs invisibly and only shows a challenge to suspicious visitors, hCaptcha shows a checkbox the visitor must tick, so leave room for it in the layout. Without the SDK, load the provider script yourself and post the token in captcha.token_field.
New forms get the captcha by default once Crafty has captcha keys configured; it is the Proteger con captcha box in the form's settings in the panel, or captcha: false on create_form. Turning it on for a form on a site that does not render the widget makes every submission fail with 422, so update the frontend first.
Note the asymmetry:
collections.get()returns the bare object, whileforms.get()andentries.get()are wrapped in{ data }.
mediaCollections
crafty.mediaCollections.list(query?, options?) // => { data: CraftyMediaCollection[], meta: { includes } }
query.include accepts "stats", "directories", or an array of them (de-duplicated and serialized comma-separated).
mediaFiles
crafty.mediaFiles.list(query?, options?) // => { data: CraftyMediaFile[], links, meta: { ...pagination, ttl_minutes } }
query: collection, directory_id, ttl_minutes (signed-URL lifetime), per_page, page. Each file has url, is_public, expires_at and image_transform_base; pass the file to crafty.image() to render it.
llms.txt
crafty.llms fetches and serves the llms.txt (index) / llms-full.txt (full content) files Crafty generates from the site's published pages and opted-in collection entries.
crafty.llms.index(options?) // => string (the llms.txt body)
crafty.llms.full(options?) // => string (the llms-full.txt body)
crafty.llms.handler({ full?, maxAge? }) // => () => Promise<Response> (a route handler)
crafty.llms.linkTags({ full? }) // => string of <link> tags, ready to inline in <head>
crafty.llms.linkTagAttrs({ full? }) // => LlmsLinkTag[] (objects, for useHead / <Head>)
handler(opts) returns a zero-argument function you can assign directly to a framework's route export; it fetches full() when opts.full is true (otherwise index()) and responds with content-type: text/markdown; charset=utf-8 and cache-control: public, max-age=${opts.maxAge ?? 300}.
// Next.js — app/llms.txt/route.ts
export const GET = crafty.llms.handler();
// Next.js — app/llms-full.txt/route.ts
export const GET = crafty.llms.handler({ full: true, maxAge: 3600 });
// Astro — src/pages/llms.txt.ts
export const GET = crafty.llms.handler();
// Nuxt (h3) — server/routes/llms.txt.ts
export default defineEventHandler(() => crafty.llms.handler()());
For a static build, call index() / full() directly and write the result into dist/ at build time instead of serving a route:
import { writeFile } from "node:fs/promises";
await writeFile("dist/llms.txt", await crafty.llms.index());
await writeFile("dist/llms-full.txt", await crafty.llms.full());
linkTags() / linkTagAttrs() advertise the files in <head> (rel="alternate", type="text/markdown"); pass { full: true } to also include the llms-full.txt link.
Content and URLs: title, summary, language, and which collections without a route are opted in are configured on the "llms.txt" settings screen in the admin, or via the configure_llms MCP tool. URLs are resolved from the visual editor routes — a collection without a route pattern is excluded by default. Sites hosted by Crafty get both files injected at the site root on their next deploy (a deploy already made before you started using this feature is not retroactively updated); a file the site's own build already ships wins over the injected one.
Errors: index() / full() (and therefore handler()) throw CraftyError with code SERVER_ERROR if the response body is not text.
Analytics
crafty.analytics serves the third-party analytics (Google Tag Manager, GA4, Meta Pixel, TikTok Pixel) configured for the API key's environment on the "Analítica" admin screen or with the configure_analytics MCP tool.
crafty.analytics.get(options?) // => { gtm_container_id, ga4_measurement_id, meta_pixel_id, tiktok_pixel_id, configured, head, body }
crafty.analytics.load(options?) // browser only => "loaded" | "already-loaded" | "not-configured" | "unavailable"
- Crafty Hosting: do nothing. The deploy injects the loaders into every HTML page and replaces any block the SDK rendered at build time.
- SSR elsewhere (preferred off Crafty Hosting): render
headbefore</head>andbodyright after<body>. Crawlers see the tags and the first page view is kept. - Client-rendered elsewhere: call
load()once. It waits for the DOM, and skips without fetching whenwindow.__CRAFTY_ANALYTICS__is already set (by the deploy-injected block or an earlier load).
Never hard-code GTM / GA4 / pixel snippets in the site, and never add GA4 both here and inside GTM (page views are counted twice).
Structured data
The public API serves schema.org JSON-LD in structured_data: on the home page (pages.get(homeSlug), the business set with configure_structured_data), on crafty.site.get(), and on each published entry of a collection with a structured data mapping (structured_data on create_collection / update_collection). It is null when nothing is configured or a required property is empty.
crafty.site.get(options?) // => { name, slug, environment, default_locale, locales, structured_data }
crafty.structuredData(...sources) // => '<script type="application/ld+json">…</script>' tags, "" when none
crafty.jsonLd(...sources) // => the JSON-LD objects, for useHead / <Head>
crafty.faqPage([{ question, answer }]) // => FAQPage JSON-LD for a FAQ block, or null
Sources can be pages, entries, their API responses, list responses, the site or JSON-LD objects; FAQPage objects are merged into one per page. The script content escapes <, so it is safe to inject as raw HTML (set:html, v-html, dangerouslySetInnerHTML). Validate the result at https://search.google.com/test/rich-results. On Crafty Hosting the deploy also adds the business JSON-LD to the home page (index.html) when the page does not render it already, so a site gets LocalBusiness even without the helper.
Online store
When the store is on (the site's "Tienda" tab), products are entries of the productos collection: list them with entries.list("productos") (fields nombre, precio, disponible, variantes, imagenes, sku, impuesto). Never compute totals in the frontend: the cart holds slugs, variants and quantities only, Crafty prices it, and the visitor finishes on Crafty's hosted checkout (delivery or pickup, cash, pay at pickup or bank transfer). Store calls need siteSlug in the client config; the API key, when sent, picks its environment.
import { createCart } from "@craftware/crafty-sdk/cart";
crafty.store.get() // => { enabled, currency, taxes, delivery, pickup, payment_methods, checkout_url, … }
crafty.orders.quote(cart.items(), { fulfillment }) // => { items, unavailable, subtotal, delivery_fee, taxes, total } as decimal strings
crafty.checkout.start(cart.items(), { returnUrl, cancelUrl }) // creates the session and sends the browser to the hosted checkout
crafty.checkout.create(cart.items(), options) // => { id, url, expires_at } without redirecting
crafty.orders.get(number, token) // => the order, with the token from its page link (?t=)
const cart = createCart(); // localStorage, key "crafty:cart" (pass { key } per site)
cart.add("geisha", { variant: "Molido", quantity: 2 });
cart.update("geisha", 3, "Molido"); cart.remove("taza"); cart.count(); cart.subscribe(render);
cart.clearOnReturn(); // on the return page: empties the cart when the URL has ?order=, returns the number
quote().unavailable lists lines Crafty left out (not_found, sold_out, variant_not_found, variant_required, no_price). A closed store answers 404 STORE_DISABLED. After the order, "Volver a la tienda" leads to returnUrl (or the store's setting) with ?order=<number>.
Error handling
The SDK throws CraftyError for HTTP, timeout, abort, network, and config failures.
import { CraftyError } from "@craftware/crafty-sdk";
try {
await crafty.pages.get("home");
} catch (error) {
if (error instanceof CraftyError) {
console.error(error.code, error.status, error.message, error.requestId);
}
}
Codes: NETWORK_ERROR, TIMEOUT, BAD_REQUEST (HTTP 400 and 422), UNAUTHORIZED (401), FORBIDDEN (403), NOT_FOUND (404), RATE_LIMITED (429), SERVER_ERROR (5xx), UNKNOWN_ERROR (any other / unmapped status). error.requestId is populated when the API returns an x-request-id header.
page.requireBlock()throws a plainError, not aCraftyError— handle it separately.
End-to-end example
import { createCraftyClient, CraftyError } from "@craftware/crafty-sdk";
const crafty = createCraftyClient({
baseUrl: "https://<your-tenant>.crafty.do",
apiKey: process.env.CRAFTY_API_KEY,
siteSlug: "main-site",
timeoutMs: 10_000,
});
const home = await crafty.pages.get("home");
const hero = home.block("hero");
const { data: posts } = await crafty.entries.list("posts", { per_page: 15, sort_order: "desc" });
const { data: collections } = await crafty.collections.list();
const media = await crafty.mediaCollections.list({ include: ["stats", "directories"] });
const files = await crafty.mediaFiles.list({ collection: "library", per_page: 24 });
try {
await crafty.forms.submit("contact", { name: "Jane", email: "[email protected]" });
} catch (e) {
if (e instanceof CraftyError && e.code === "BAD_REQUEST") {
// missing siteSlug, or validation failed
}
}
Visual editor helpers
attrs(target, field, { label? })/crafty.attrs(...)→{ "data-crafty-block" | "data-crafty-entry", "data-crafty-field", "data-crafty-label"? }to spread on the element that renders the value;{}when the target has no valid id.fieldis a field key or a path into a list or JSON value (sections[3].title,items[2].titlefor a block-typerepeater,tags[1]for astringList,items[0].tags[1]for a stringList inside a repeater item). The index is the item's position in the stored data.attrsHtml(target, field, { label? })→ the same attributes as an escaped HTML string.image(media, options?)/crafty.image(...)andsrcset(media, widths, options?)/crafty.srcset(...)→ resized image URLs; see "Images: always go throughcrafty.image()".previewFromRequest(req)→ preview token of an incoming request (URL string,URL, WebRequest, Nodereq.url, Expressreq.query) ornull.readPreviewToken()→ token in the browser, ornull.enableVisualEditing(options)from@craftware/crafty-sdk/visual-editing→ connects the page to the editor; no-op outside the admin iframe. Options:onRefresh,adminOrigins,page,entries,touchHighlightMs.
Full integration checklist: visual-editor.md.
Notes
Accept: application/jsonis set automatically unless overridden.baseUrlis normalized so/api/v1is never duplicated.- The
apiKey(X-API-Key) is an environment token and is distinct from the Bearer tokens used for the MCP server.