The API gets content onto your site. This page gets editors off your back. Add one script tag, sprinkle a few data-crm-* attributes over your markup, and the people who own the words can click a heading on the real, live site, fix the typo and hit save — no ticket, no deploy, no “can you just change one comma on the homepage?” at 6 p.m. on a Friday.
Setting it up takes four steps, and only one of them involves thinking:
Add the widget script
One<script>tag on every page.Allow the CRM to frame your site
One response header, so the CRM can open your site inside its Live mode.Mark up editable elements
Tell the editor which element shows which block withdata-crm-*attributes.Render fresh content in Live mode
Skip your cache while an editor is looking, so changes show up instantly.
What editors get
From the editor's chair, live editing looks like this:
- They open your site in Live mode inside the CRM. It is your real site, not a mock-up.
- Every element you marked up gets a frame on hover. They click the one they want — a heading, a paragraph, a picture.
- They change the text or upload a new image and press save.
- The page refreshes with the new content. Done. Nobody opened a code editor.
If an element is marked up with a block marker that does not exist in the CRM yet, the editor can create the block right from the site. So you can ship the markup first and let the content team fill it in later.
How it works under the hood
Your page keeps rendering content from the Content API exactly as before. The data-crm-* attributes don't render anything — they just connect a DOM element with a block in the CRM, like a label on a drawer.
- Live mode is an iframe. The CRM loads your site in a frame and adds
?crm_live=1to the URL. The editor loads only inside that frame — when the referrer is the CRM, or the URL has?crm_liveand the referrer is empty or your own site — so if someone else embeds your site in their iframe, no editor shows up there. - One script loads what is needed, and only that.
widget.jsis the only tag you add. The editor itself (widget.editor.js) is loaded only when the site is opened inside the CRM Live mode. The support chat (widget.support.js) comes along only if chat is enabled in the CRM. Visitor sign-in (widget.auth.js) only if the page hasdata-crm-loginordata-crm-authelements or adata-crm-keyattribute. - Visitors don't pay for it. Outside the CRM nothing editor-related is loaded — your visitors never download the editor.
- Saving is a normal CRM edit. The CRM writes the block, the site's content version goes up, and the next API request returns fresh data.
- Included the tag twice by accident? No harm done: the second copy is ignored.
Step 1. Add the widget script
Put the tag on every page, right before </body>. If your site has a shared layout, that is the one place to put it.
<!doctype html>
<html lang="en">
<head>…</head>
<body>
…your page…
<script src="https://widget.sitecog.com/widget.js" defer></script>
</body>
</html>// app/layout.tsx
import Script from 'next/script';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Script src="https://widget.sitecog.com/widget.js" strategy="afterInteractive" />
</body>
</html>
);
}<!-- index.html in the project root -->
<!doctype html>
<html lang="en">
<head>…</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script src="https://widget.sitecog.com/widget.js" defer></script>
</body>
</html>That is it for the script. No key, no init call, no config object — the widget figures out on its own whether it is running inside the CRM.
Step 2. Allow the CRM to frame your site
Live mode shows your site in an iframe on https://sitecog.com. Browsers only allow that if your site says so. Your responses need two things:
- a
Content-Security-Policyheader withframe-ancestors 'self' https://sitecog.com; - no
X-Frame-Optionsheader withDENYorSAMEORIGIN— it overrules good intentions and blocks the frame.
Pick your server:
server {
# …
# Let the Diil CRM open the site in Live mode
add_header Content-Security-Policy "frame-ancestors 'self' https://sitecog.com" always;
# Delete any "add_header X-Frame-Options …" lines in this server block.
# If the app behind proxy_pass sets X-Frame-Options itself, drop it here:
proxy_hide_header X-Frame-Options;
}# .htaccess or the VirtualHost config (needs mod_headers)
<IfModule mod_headers.c>
Header always set Content-Security-Policy "frame-ancestors 'self' https://sitecog.com"
Header always unset X-Frame-Options
</IfModule>// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Content-Security-Policy',
value: "frame-ancestors 'self' https://sitecog.com",
},
],
},
];
},
};
export default nextConfig;// Before your routes
app.use((req, res, next) => {
res.removeHeader('X-Frame-Options');
res.setHeader('Content-Security-Policy', "frame-ancestors 'self' https://sitecog.com");
next();
});
// Using helmet? It sends X-Frame-Options: SAMEORIGIN by default. Configure it instead:
// app.use(helmet({
// xFrameOptions: false,
// contentSecurityPolicy: {
// directives: { frameAncestors: ["'self'", 'https://sitecog.com'] },
// },
// }));Step 3. Mark up editable elements
Now tell the editor what is what. Each attribute says “this element shows that block”. The value is a path that starts with the block marker you set in the CRM.
Attribute reference
| Attribute | Put it on | What editors can do |
|---|---|---|
data-crm-text | Any element that shows text: h1, p, span, a button label | Edit the text of a text block or a text field |
data-crm-image | The <img> that shows an image block or field | Upload or replace the picture |
data-crm-video | The <video> that shows a video block or field | Upload or replace the video |
data-crm-object | The container that renders an object block (a section, a card) | See the group of fields as one block |
data-crm-array | The container that renders a list — an array block or an array field | See the list as one whole |
Simple blocks need nothing more than their marker:
<section>
<h1 data-crm-text="hero_title">{hero.hero_title.content.en}</h1>
<p data-crm-text="hero_subtitle">{hero.hero_subtitle.content.en}</p>
<img data-crm-image="hero_image" src={hero.hero_image.content.file} alt="" />
<video data-crm-video="hero_video" src={hero.hero_video.content.file} autoPlay muted loop />
</section>Path syntax: reaching inside objects and arrays
Object and array blocks have fields inside, so the path keeps going with dots. The first segment is always the block marker. After it come object field markers and numeric array indexes (starting at 0).
| Path | Points at |
|---|---|
hero_title | The whole block hero_title |
faq_section.title | Field title of the object block faq_section |
faq_section.items | The array field items |
faq_section.items.0.question | Field question of the first item |
faq_section.items.2.answer | Field answer of the third item |
The rules fit in four lines:
- segments are separated by dots; each one is Latin letters, digits and underscores, 1–40 characters;
- the first segment, the block marker, is at least 2 characters long (the usual marker rules);
- a step into an object is a field marker, a step into an array is a number;
- markers are case-sensitive:
Hero_titleandhero_titleare two different blocks.
Full example: an FAQ section
Here is the data: one object block with a title and an array of questions.
"faq_section": {
"type": "object",
"content": {
"title": { "en": "FAQ" },
"items": [
{
"question": { "en": "How long is delivery?" },
"answer": { "en": "1–3 days." }
},
{
"question": { "en": "Can I return the earbuds?" },
"answer": { "en": "Yes, within 14 days." }
}
]
}
}And here is the markup. The object gets data-crm-object, the list gets data-crm-array, and every text inside gets a full path with the item index:
type LangMap = Record<string, string>;
type FaqItem = { question: LangMap; answer: LangMap };
type FaqBlock = { content: { title: LangMap; items: FaqItem[] } };
export function Faq({ block, lang }: { block: FaqBlock; lang: string }) {
const { title, items } = block.content;
return (
<section data-crm-object="faq_section">
<h2 data-crm-text="faq_section.title">{title[lang]}</h2>
<div data-crm-array="faq_section.items">
{items.map((item, i) => (
<details key={i}>
<summary data-crm-text={`faq_section.items.${i}.question`}>
{item.question[lang]}
</summary>
<p data-crm-text={`faq_section.items.${i}.answer`}>
{item.answer[lang]}
</p>
</details>
))}
</div>
</section>
);
}
// Usage: <Faq block={page.content.faq.content.faq_section} lang="en" /><section data-crm-object="faq_section">
<h2 data-crm-text="faq_section.title">FAQ</h2>
<div data-crm-array="faq_section.items">
<details>
<summary data-crm-text="faq_section.items.0.question">How long is delivery?</summary>
<p data-crm-text="faq_section.items.0.answer">1–3 days.</p>
</details>
<details>
<summary data-crm-text="faq_section.items.1.question">Can I return the earbuds?</summary>
<p data-crm-text="faq_section.items.1.answer">Yes, within 14 days.</p>
</details>
</div>
</section>Arrays inside arrays work the same way — keep alternating field markers and indexes, e.g. pricing.plans.1.features.0.text.
Step 4. Render fresh content in Live mode
On our side every CRM edit reaches the API immediately. But your site may have a cache of its own: the browser may keep API responses for up to 60 seconds, a Next.js revalidate keeps them for its own window. Visitors won't notice. An editor who just pressed save and still sees the old text will.
The fix: when the page is open in Live mode, fetch with cache: 'no-store'. You can tell Live mode by the crm_live URL parameter or by the page running inside an iframe. Our own reference site does exactly this:
// crm.ts — is the page open in the CRM Live mode?
export function isCrmLive(): boolean {
if (typeof window === 'undefined') return false;
try {
if (new URLSearchParams(window.location.search).has('crm_live')) return true;
// The parameter can get lost after an internal link — the iframe check covers that
return window.parent !== window;
} catch {
return false;
}
}
export async function getPage(marker: string) {
const res = await fetch(`https://back.sitecog.com/content/v1/pages/${marker}`, {
headers: { 'x-crm-key': 'pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40' },
// Editors always get fresh content, visitors get the fast cached one
cache: isCrmLive() ? 'no-store' : 'default',
});
if (!res.ok) throw new Error(`Content API: ${res.status}`);
return res.json();
}// app/page.tsx — a Server Component only sees the URL, so it checks crm_live
type Props = { searchParams: Promise<Record<string, string | string[] | undefined>> };
export default async function Home({ searchParams }: Props) {
const live = 'crm_live' in (await searchParams);
const res = await fetch('https://back.sitecog.com/content/v1/pages/home?lang=en', {
headers: { 'x-crm-key': process.env.CRM_KEY! },
// Live mode: straight from the API. Everyone else: cached for 60 seconds
...(live ? { cache: 'no-store' as const } : { next: { revalidate: 60 } }),
});
const page = await res.json();
const hero = page.content.hero.content;
return <h1 data-crm-text="hero_title">{hero.hero_title.content.en}</h1>;
}What is cached where, and for how long, is covered on the Caching & ETag page.
Checklist
- The
widget.jstag is on every page, before</body>. - Responses carry
frame-ancestors 'self' https://sitecog.com. - No
X-Frame-Optionsheader anywhere — check the hosting panel, CDN and framework defaults too. - Editable elements have
data-crm-*attributes, and the markers match the CRM letter for letter. - Objects and lists are wrapped in
data-crm-object/data-crm-array, and inner paths use the right indexes. - In Live mode the site fetches content with
cache: 'no-store'. - You opened the site in Live mode, clicked a heading, changed it and saw the change. 🎉
Troubleshooting
“The site forbids embedding”
The CRM tried to open your site in a frame and the browser said no. Usual suspects:
- the
frame-ancestorsdirective is missing, orhttps://sitecog.comis not in it; - something still sends
X-Frame-Options: a hosting panel, a CDN, a security plugin, helmet in Express; - the CSP is set via
<meta>instead of a header, soframe-ancestorsis ignored; - the header is configured for one host, but the site opens on another (with or without
www).
Check what your server actually sends:
curl -sI https://your-site.com | grep -iE "content-security-policy|x-frame-options"An element is not clickable in Live mode
- The attribute is missing from the rendered HTML. Inspect the page in DevTools, not the source — some components don't pass unknown props down to the DOM.
- The path is malformed: a space, a hyphen, a non-Latin letter, a trailing dot. The widget writes a warning about the marker format to the browser console.
- Only the container is marked.
data-crm-objectanddata-crm-arraygroup things; the clickable parts are the texts and images inside, and they need their owndata-crm-text/data-crm-image. - The widget script isn't on this particular page — easy to miss when a site has several layouts.
Saved, but the change is not visible
- Your fetch is cached. Use
cache: 'no-store'in Live mode (Step 4). - The page is fully static — built once at deploy time — so it can't know about new content until the next build. Make it fetch at request time, at least in Live mode.
- The element shows hardcoded text or a fallback instead of the value from the API: the attribute is there, the data isn't.
- The path points somewhere other than what is rendered — e.g. the element shows item
1but is markeditems.0. - The site key belongs to another site, or the page renders a different language than the one being edited. See Languages & fallbacks.
The widget can do more
The same widget.js tag also counts page views, sends your own events with window.crmTrack(name, params), turns form[data-crm-lead] forms into CRM leads and shows a live support chat. No extra scripts — just pick what you need: