Preparing a site for the Crafty visual editor

The visual editor lets a non-technical user open their real site inside the Crafty admin, click (or tap) a text or image, edit it in a side panel, watch the change live, and publish. It only works when the frontend is prepared with @craftware/crafty-sdk 0.3.0 or later; use 0.4.1 or later so buttons and links select instead of navigating, hrefField is available and Escape inside the site closes the admin's panels. This guide is the complete checklist.

The mandatory question

Whenever you integrate Crafty into a frontend (a new site, new pages or sections, or moving hardcoded content into Crafty), ask the user this exact question once, before you finish:

¿Quieres dejar el sitio preparado para el editor visual de Crafty?

  • Yes → run the whole "Yes flow" below.
  • No → run the "No flow".
  • If the user later asks "habilita el editor visual de Crafty" (or anything equivalent) for an existing site, do not ask again: run the Yes flow.

No flow

Add nothing: no attrs(), no enableVisualEditing(), no preview token handling, no headers, no configure_visual_editor call. End your final message with this sentence for the user:

El editor visual de Crafty no quedó activado. Cuando quieras activarlo, pide: «habilita el editor visual de Crafty».

Yes flow

Do every step. Keep changes minimal and in the site's existing style.

1. Decide the render mode

render_mode How to recognise it Token strategy (step 4)
ssr Content is fetched on the server for every request: Next.js dynamic routes / Server Components without static caching, Nuxt SSR, Astro output: "server", Remix loaders, Express/Fastify templates. Read it per request with previewFromRequest().
hybrid HTML is prerendered at build time and the browser fetches Crafty again on load (the Vite + prerender + client refresh pattern). Automatic in the browser.
static Content is baked at build time only; the browser never calls the Crafty API. Add a browser re-fetch that only runs in preview.
unknown You cannot tell. Treat it like hybrid and tell the user.

2. Upgrade the SDK

npm install @craftware/crafty-sdk@^0.4.1

Right after an SDK release npm may still hold stale registry metadata and fail with ETARGET (no matching version). Retry with npm install @craftware/crafty-sdk@^0.4.1 --prefer-online, which re-checks the registry instead of trusting the cache.

The package ships this skill. If the project keeps its own copy (for example .claude/skills/crafty-cms), refresh it after every SDK upgrade so it matches the installed version:

rm -rf .claude/skills/crafty-cms
cp -R node_modules/@craftware/crafty-sdk/skills/crafty-cms .claude/skills/crafty-cms

3. Mark every editable element

Use attrs(target, field, { label }) (also available as crafty.attrs on the client). target is the block or entry the value came from; field is the field key or the JSON path of the value.

Rules:

  • Mark only content that comes from Crafty. Never mark hardcoded text.
  • Put the attributes on the innermost element whose entire text is the field value (<h1>, <p>, <span>, the <a> or <button> label, <li>). Live preview replaces that element's text, so an element that also contains icons or other fields must not be marked; wrap the value in a <span> and mark the span.
  • Images from a media reference field: mark the <img> with the field key (image, photo).
  • Buttons and links (CTAs): mark the label and pass the destination field as hrefField, so the editor shows the text and an «Enlace» input together and saves each as its own draft. A click anywhere on a link selects its marked label (the editor never follows links); on a real <button>, <summary> or <label> only a click on the marked element selects, so menus and accordions keep working. A link whose only editable value is its destination (a url field) is marked on the <a> itself; destinations update after saving the draft, not live while typing.
<Link to={cta.href} className="btn">
  <span {...attrs(entry, `sections[${i}].cta.label`, { label: "Texto del botón", hrefField: `sections[${i}].cta.href`, hrefLabel: "Enlace del botón" })}>{cta.label}</span>
  <Arrow />
</Link>
  • Pass hrefField only when the content really stores a destination. If the site renders a fallback when the value is missing (cta.href || "/#contact"), there is nothing in Crafty to edit: the editor cannot create a missing key inside a json field, so it hides the «Enlace» input for that element. A small helper keeps this consistent:
// Adds the link field only when the stored item has a destination.
export function linkTo(href, field, label = "Enlace") {
  return typeof href === "string" && href.trim() !== "" ? { hrefField: field, hrefLabel: label } : {};
}

<span {...attrs(entry, `sections[${i}].cta.label`, { label: "Texto del botón", ...linkTo(cta.href, `sections[${i}].cta.href`, "Enlace del botón") })}>{cta.label}</span>
  • hrefField is resolved on the same block or entry as the label. When the label and the destination live in different targets (the label in a page section, the URL in the site-settings entry), they cannot be paired: mark them separately, the label on its <span> with its own target and the destination on the <a> with the other target (as a link whose only editable value is its destination).
  • Hardcoded link text is not editable and must not be marked. For a link like <a href={settings.portal_url}>Portal</a>, mark the <a> with the url field only: the editor edits the destination, the text «Portal» never changes, and the new destination shows after saving. If the client must be able to change that text, move it into a Crafty field first and mark it like any other label.
  • Values inside json fields: use the exact path of the rendered value, e.g. sections[2].title, hero.title_segments[0].text, extra.ctaPrimaryLabel. Array indexes are positions in the stored JSON. When you render the array as stored, the loop index is that position (`sections[${i}].title`). When you filter, sort, split or reorder it (featured vs regular products, items sorted by order, only visible sections), keep the stored index with each item before transforming it and use that, never the rendered index:
// Keep the stored position before filtering or sorting.
const items = section.items.map((item, index) => ({ item, index }));
const featured = items.filter(({ item }) => item.featured).sort((a, b) => a.item.order - b.item.order);

featured.map(({ item, index }) => (
  <h3 key={index} {...attrs(entry, `sections[${sectionIndex}].items[${index}].title`, { label: "Título del producto" })}>{item.title}</h3>
));

Section components that receive one slice of a JSON field can take a path-scoped helper instead of the whole path:

// editable(entry).at(`sections[${i}]`)("title", "Título") -> attrs(entry, `sections[${i}].title`, { label: "Título" })
export function editable(target, base = "") {
  const join = (path) => (base ? `${base}.${path}` : path);
  const edit = (field, label, options = {}) => attrs(target, join(field), { ...options, ...(label ? { label } : {}), ...(options.hrefField ? { hrefField: join(options.hrefField) } : {}) });
  edit.at = (path) => editable(target, join(path));
  return edit;
}
  • Block-type lists are editable per item: for a repeater use items[${i}].title (the stored index, same rule as json arrays) and for a stringList tags[${i}] or items[${i}].tags[${j}]. Only subfields of type string, text, longText, url or number are editable, and the item must already exist in the stored data. Mark the individual values, not the whole item.
  • Editable types: string, text, longText, url, number, media reference, and string/number values inside json. Do not mark booleans, dates, selects, multi-references, or array items that are objects.
  • label is a short Spanish name the client understands ("Título principal", "Texto del botón"). Without it the editor shows the field key.
  • Pass blocks and entries as the API returns them. If an entry has fields named order, data and type at once, pass { kind: "entry", id: entry.id } instead.

React / Next.js / Astro (JSX):

import { attrs } from "@craftware/crafty-sdk";

<h1 {...attrs(block, "title", { label: "Título principal" })}>{block.data.title}</h1>
<img {...attrs(block, "image", { label: "Imagen principal" })} src={imageUrl} alt="" />

{sections.map((section, i) => (
  <section key={i}>
    <h2 {...attrs(entry, `sections[${i}].title`, { label: "Título de la sección" })}>{section.title}</h2>
    <p {...attrs(entry, `sections[${i}].body`, { label: "Texto de la sección" })}>{section.body}</p>
  </section>
))}

Vue:

<h1 v-bind="attrs(entry, 'hero.title', { label: 'Título principal' })">{{ entry.hero.title }}</h1>

HTML string templates (vanilla TS):

import { attrsHtml } from "@craftware/crafty-sdk";

html += `<h1 ${attrsHtml(entry, "extra.title", { label: "Título" })}>${escapeHtml(extra.title)}</h1>`;

4. Deliver the preview token

The editor loads the site with ?crafty_preview=<token>. Requests that carry it as X-Crafty-Preview get unpublished drafts and bypass the API cache.

Shared preview links (panel "Compartir vista previa" or the MCP create_preview_link) use the same parameter with a cps_… token. They last 1 to 30 days, can be revoked, and unlock only their own page or entry: the rest of the site answers as published. The same setup below serves both, so nothing changes in the frontend.

  • hybrid / SPA: nothing to do. A client created in the browser reads the token from the URL, keeps it in sessionStorage, and sends it on every request. Make sure the browser re-fetch really runs on every page load.
  • ssr: create the client per request with the token, never share a module-level client that received one, and never cache a preview response:
import { createCraftyClient, previewFromRequest } from "@craftware/crafty-sdk";

// Express / Node: previewFromRequest(req)   Web Request: previewFromRequest(request)
// Next.js App Router: previewFromRequest({ query: await searchParams })
const preview = previewFromRequest(req);
const crafty = createCraftyClient({ baseUrl: process.env.CRAFTY_URL!, apiKey: process.env.CRAFTY_API_KEY!, preview });

if (preview) {
  res.setHeader("Cache-Control", "no-store"); // Next.js: call noStore() / use dynamic rendering for this request
}

Server-rendered pages should also tell the editor what they show (step 5, page / entries).

  • static: the built HTML has no drafts, so re-fetch in the browser only when previewing and re-render:
import { readPreviewToken } from "@craftware/crafty-sdk";

if (readPreviewToken()) {
  const fresh = await crafty.entries.list("site-pages");
  renderPage(fresh.data); // the same function the build uses
}

If the site cannot re-render in the browser, set render_mode to static anyway and tell the user that the editor will show changes only after saving and reloading, and the public site only after a deploy.

5. Turn on the bridge

Call it once in the browser entry (it keeps watching the DOM, so it can run before or after the first render):

import { enableVisualEditing } from "@craftware/crafty-sdk/visual-editing";

enableVisualEditing({
  onRefresh: () => reloadCraftyContent(), // re-fetch + re-render; omit it to let the editor reload the page
});
  • It does nothing for normal visitors (it needs the admin iframe, a token, and an allowed admin origin), so ship it to production.
  • Even then it stays inert until the backend confirms the token — a response with X-Crafty-Preview-Status: active, normally the site's own first Crafty request for that page. ssr sites where the browser never calls the Crafty API itself must pass client (a CraftyClient, typically the same one already created for that render mode) so the bridge can confirm the token itself with one lightweight request (pages.list({ per_page: 1 })): enableVisualEditing({ client: crafty }). hybrid and static sites already make a browser-side request that carries the header, so client is optional for them. If the API answers invalid, the bridge stays off and tells the admin instead of activating.
  • If the site fetches Crafty through something other than this SDK's client (so neither path above ever runs), report X-Crafty-Preview-Status yourself with notifyPreviewActive() / notifyPreviewInvalid() from @craftware/crafty-sdk/visual-editing (also exports getPreviewStatus() and the PREVIEW_ACTIVE_EVENT / PREVIEW_INVALID_EVENT event names).
  • onRefresh: register it when the site can re-fetch Crafty and re-render without a reload (SPAs, hybrid sites). Otherwise omit it.
  • React, Vue and other frameworks: live preview writes text straight into the DOM, into nodes the framework owns. Reconciling against those nodes after a refresh can leave stale text or crash (for example when the element also renders <strong> from an emphasis helper). In onRefresh, re-fetch and then remount the tree (change a key on the root) instead of patching it:
// main.tsx (React)
let remount = () => {};
function Root() {
  const [version, setVersion] = useState(0);
  remount = () => setVersion((v) => v + 1);
  return <App key={version} />;
}
enableVisualEditing({
  onRefresh: async () => {
    await refreshCrafty(); // re-fetch and update the content cache below
    remount();
  },
});

Vue: the same with <App :key="version" /> on the root component.

  • Hybrid sites: cache the latest fetch. A remount (or a client-side navigation) must render the content fetched last (with drafts), not the build-time data embedded in the HTML. Keep the latest response per collection or page in a module-level cache, read it first when a component mounts, and update it in the refresh:
const cache = new Map<string, unknown>();
export async function loadCollection(slug: string) {
  const fresh = await crafty.entries.list(slug);
  cache.set(slug, fresh.data);
  return fresh.data;
}
export const cachedCollection = (slug: string, buildTime: unknown) => cache.get(slug) ?? buildTime;
export const refreshCrafty = () => Promise.all([...cache.keys()].map(loadCollection));
  • SSR: pass what the server rendered, e.g. enableVisualEditing({ client: crafty, page: { id: page.id, slug: page.slug }, entries: [{ id: post.id, collection: "blog" }] }).
  • Escape (SDK 0.4.1+): while the bridge is active, an Escape pressed inside the site is forwarded to the admin so its bottom sheet and dialogs close. If the site closes its own menu or modal on Escape, that handler must call event.preventDefault(); otherwise one press closes both the site's menu and the admin's panel.
  • adminOrigins: only when the tenant's admin is not on https://<tenant>.crafty.do (custom admin domain, or local development such as http://<tenant>.crafty.test). It replaces the default list.
  • No bundler:
<script src="https://cdn.jsdelivr.net/npm/@craftware/[email protected]/dist/crafty-visual-editing.global.js"></script>
<script>CraftyVisualEditing.enableVisualEditing();</script>

Pin an exact version (@0.4.1) with an integrity="sha384-…" crossorigin="anonymous" attribute (jsDelivr shows the hash) when the site uses Subresource Integrity.

6. Allow the admin to frame the site

The site must accept being framed by the admin. Sites deployed to Crafty Hosting get this header from Crafty; for any other host add it (and remove any X-Frame-Options: DENY / SAMEORIGIN):

Content-Security-Policy: frame-ancestors 'self' https://<tenant>.crafty.do
  • Netlify / Cloudflare Pages (_headers in the build output):
/*
  Content-Security-Policy: frame-ancestors 'self' https://<tenant>.crafty.do
  • Vercel (vercel.json):
{ "headers": [{ "source": "/(.*)", "headers": [{ "key": "Content-Security-Policy", "value": "frame-ancestors 'self' https://<tenant>.crafty.do" }] }] }
  • Nginx: add_header Content-Security-Policy "frame-ancestors 'self' https://<tenant>.crafty.do" always;

If the site already sends a Content-Security-Policy, add the frame-ancestors directive to it instead of sending a second header.

7. Make sure images come from public repositories

Static and hybrid sites keep image URLs in their HTML; private repositories give expiring URLs. Check with list_media (each file has is_public) or crafty.mediaCollections.list(). If images the site shows live in a private repository, tell the user: «Las imágenes del repositorio X son privadas y sus enlaces caducan en sitios estáticos. Márcalo como público en Crafty → Media.» Uploads made from the visual editor go to the public "Editor visual" repository by default.

8. Configure Crafty

Call configure_visual_editor (scope crafty:write) with what you learned:

{
  "site_id": 3,
  "environment": "production",
  "frontend_url": "https://www.example.com",
  "routes": {
    "page_pattern": "/{slug}",
    "home_slug": "home",
    "collections": { "blog": "/blog/{slug}" },
    "fixed": ["/", "/en", "/blog"]
  },
  "render_mode": "hybrid"
}
  • frontend_url: the public URL of that environment where this build runs (omit it when it is the site's domain in production or the Crafty Hosting URL). Before setting a domain, check which host actually serves it: a domain can point at a different app than the one you prepared (another server, an older site), and then the editor frames a page without the SDK.
curl -sI https://www.example.com/ | grep -iE "^(server|x-frame-options|content-security-policy|set-cookie|location)"
curl -sI https://<site>.pages.crafty.do/ | grep -iE "^(server|x-frame-options|content-security-policy)"

Crafty Hosting answers with content-security-policy: frame-ancestors 'self' https://<tenant>.crafty.do. x-frame-options: deny, a foreign framework cookie (for example laravel_session) or a different server means another app serves that domain: do not use it as frontend_url; use the Crafty Hosting URL (omit frontend_url) or the URL that really serves this build.

  • routes.page_pattern / home_slug: only if the site renders Crafty pages; patterns start with / and contain {slug}.
  • routes.collections: collections whose entries have their own URL; patterns contain {slug} or {id}.
  • routes.fixed: every other URL that shows Crafty content (home in other languages, listing pages, single-page sites: just ["/"]).
  • render_mode: from step 1.
  • Repeat with environment: "development" (or others) when they have their own URL.
  • Calling it with only site_id returns the current settings without changing anything.

9. Verify

  1. Build and type-check the site.
  2. Run it locally and open a page with ?crafty_preview=test. In DevTools:
    • document.querySelectorAll("[data-crafty-field]").length is greater than 0 on every page type (home, a page, a collection entry).
    • Requests to the Crafty API carry X-Crafty-Preview: test, and the responses say X-Crafty-Preview-Status: invalid (expected: test is not a real token; the content stays public).
    • Nothing is highlighted: outside the admin iframe the bridge stays off.
    • The console has no hydration errors (React #418/#423, Vue "Hydration mismatch"). Compare with a build without your changes: if they were already there, say so to the user instead of hiding them; if they are new, the marked markup differs between the prerender and the client render.
    • Prerendered (hybrid/static) pages first show the published content and then the drafts once the browser re-fetch finishes. A short flash of published text in preview is expected; if drafts never appear, the preview re-fetch is missing or a remount reads build-time data (see the cache in step 5).
  3. After deploying, ask the user to open Crafty → the site → Editar sitio (https://<tenant>.crafty.do/sites/<site_id>/visual-editor?path=/). If hovering highlights texts and a click opens the field, the editor received crafty:ready. If it shows «Este sitio aún no tiene edición con clic activada», go through the troubleshooting list.
  4. Check the phone layout too. The editor's Móvil toggle narrows the framed site to phone width, which is enough to test taps on the site. The admin's own phone layout (a bottom sheet instead of the side panel, closed with its × button or Escape, also from inside the site with SDK 0.4.1 or later) only appears when the browser window itself is narrower than 1024 px: shrink the window or use DevTools device mode on the admin tab.

End with a short summary for the user in Spanish: what was marked, the render mode, and what they must do after publishing (nothing for ssr/hybrid; a deploy for static).

Troubleshooting

  • The editor shows «Este sitio aún no tiene edición con clic activada» — the iframe is blocked (browser console: frame-ancestors or X-Frame-Options), enableVisualEditing() is not in the bundle of that page, the SDK is older than 0.3.0, the admin origin is not allowed (custom admin domain → adminOrigins), a redirect dropped the query string (trailing-slash or locale redirects must keep ?crafty_preview=), or the site is ssr and never confirmed the token because client was not passed to enableVisualEditing().
  • «Esta URL no mostró la página …» — routes do not match the site's URLs; fix them with configure_visual_editor.
  • Saved drafts do not appear in the editor — SSR: the client was created once at module level or the response was cached; static: the preview re-fetch is missing.
  • Text reverts or the page crashes after saving — a framework reconciled DOM that live preview had changed; remount in onRefresh (step 5).
  • Typing does not update the text live — the attributes are on a wrapper that contains more than the value, or the field is rich text (longText shows after saving the draft).
  • Clicking a menu link opens its text instead of the page — expected: with the bridge active, marked menu labels are selected for editing, not followed. Change pages with the editor's route selector (unmarked internal links still navigate).
  • Clicking an editable card navigates away — the SDK is older than 0.4.0 (earlier versions only selected when the click landed exactly on the marked element) or nothing inside the link is marked; mark its label.