Five minutes, one page, zero admin panels. By the end of this guide a headline on your site will come from the Diil CRM — and your editors will be able to change it by clicking on it. Grab a coffee; you might not finish it.
You will need:
- access to a site in the Diil CRM (you can create a fresh one for experiments);
- a terminal with
curl, or any tool that can send an HTTP request; - any frontend: a plain HTML file, React, Next.js, Nuxt — your call.
Set up content and a key
Create some content in the CRM
Open your site in the CRM and make sure the languages you need are added (say, English). Then create:
- a page with the marker
home; - inside it, a section with the marker
hero; - inside that, a text block with the marker
hero_title— type a headline into it.
Markers are the names your code uses to find content: Latin letters, digits and underscores, 2–40 characters, case-sensitive. Pick them like variable names — they will live in your code for a long time. Here is the full picture of pages, sections and blocks.
- a page with the marker
Get a site key
Go to Settings → Content API keys and create a key. It looks like
pk_followed by 32 hex characters and is bound to this one site.A new key usually works right away; in the worst case give it a few minutes. Revoking works the same way — revoked keys get
401 invalid_key.Make your first request
Ask for the
homepage in English:curl "https://back.sitecog.com/content/v1/pages/home?lang=en" \ -H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"const res = await fetch('https://back.sitecog.com/content/v1/pages/home?lang=en', { headers: { 'x-crm-key': 'pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40' }, }); console.log(await res.json());200 OKjson { "id": 26, "marker": "home", "name": "Home", "href": "/", "index": 0, "params": {}, "content": { "hero": { "id": 41, "marker": "hero", "name": "Hero", "index": 0, "show": true, "content": { "hero_title": { "id": 95, "marker": "hero_title", "name": "Hero title", "type": "text", "multilang": true, "updatedAt": "2026-09-20T16:33:23.000Z", "content": { "en": "Earbuds that mute the city" } } } } } }See the path to your headline?
content.hero.content.hero_title.content.en— page → section → block → language. Every page has exactly this shape; the details are in Pages.
Render it on your page
Same request, four flavours. Pick yours — they all do the same thing: fetch the page and put the headline into an <h1>.
<h1 id="hero-title"></h1>
<script type="module">
const res = await fetch('https://back.sitecog.com/content/v1/pages/home?lang=en', {
headers: { 'x-crm-key': 'pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40' },
});
const page = await res.json();
const hero = page.content.hero.content;
document.getElementById('hero-title').textContent = hero.hero_title.content.en;
</script>// Hero.jsx — a client component (Vite, CRA, anything)
import { useEffect, useState } from 'react';
const API = 'https://back.sitecog.com/content';
const KEY = import.meta.env.VITE_CRM_KEY; // public key, fine in the browser
export function Hero() {
const [hero, setHero] = useState(null);
useEffect(() => {
fetch(API + '/v1/pages/home?lang=en', { headers: { 'x-crm-key': KEY } })
.then((res) => res.json())
.then((page) => setHero(page.content.hero.content));
}, []);
if (!hero) return null;
return <h1>{hero.hero_title.content.en}</h1>;
}// app/page.tsx — a Server Component: runs on the server, the key stays there
const API = 'https://back.sitecog.com/content';
export default async function Home() {
const res = await fetch(API + '/v1/pages/home?lang=en', {
headers: { 'x-crm-key': process.env.CRM_KEY! },
next: { revalidate: 60 }, // ask the API at most once a minute
});
if (!res.ok) throw new Error('Content API: ' + res.status);
const page = await res.json();
const hero = page.content.hero.content;
return <h1>{hero.hero_title.content.en}</h1>;
}<!-- pages/index.vue -->
<script setup lang="ts">
const config = useRuntimeConfig(); // NUXT_PUBLIC_CRM_KEY → config.public.crmKey
const { data: page } = await useFetch('https://back.sitecog.com/content/v1/pages/home', {
query: { lang: 'en' },
headers: { 'x-crm-key': config.public.crmKey },
});
const hero = computed(() => page.value?.content.hero.content);
</script>
<template>
<h1>{{ hero?.hero_title.content.en }}</h1>
</template>Where to keep the key
Put the key in an environment variable rather than in the code — not because it is a secret, but because it makes switching sites or rotating keys a one-line change.
# .env.local (Next.js) — server-only, never sent to the browser
CRM_KEY=pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40
# Browser code needs a public variable. That's fine: the key is read-only.
NEXT_PUBLIC_CRM_KEY=pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40
# Vite: VITE_CRM_KEY=… Nuxt: NUXT_PUBLIC_CRM_KEY=…In a server component (like the Next.js tab above) use the server-only variable: the key never leaves your server. For browser code a public variable is perfectly fine.
Add a language helper
Text blocks come as language maps: { "en": "…", "de": "…" }. The API never swaps one language for another: if a translation is missing, it is simply not there (or it is an empty string if the editor left the field blank). Choosing a fallback is your call — and this tiny helper makes it a one-liner:
// lib/t.js
// Pick a translation: requested language → fallback language → first non-empty → ''
export function t(map, lang, fallback = 'en') {
if (!map) return '';
return map[lang] || map[fallback] || Object.values(map).find(Boolean) || '';
}// lib/t.ts
export type LangMap = Record<string, string>;
// Pick a translation: requested language → fallback language → first non-empty → ''
export function t(map: LangMap | null | undefined, lang: string, fallback = 'en'): string {
if (!map) return '';
return map[lang] || map[fallback] || Object.values(map).find(Boolean) || '';
}// Ask for the visitor's language and the fallback in one request
const res = await fetch('https://back.sitecog.com/content/v1/pages/home?lang=de,en', {
headers: { 'x-crm-key': 'pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40' },
});
const page = await res.json();
const hero = page.content.hero.content;
t(hero.hero_title.content, 'de'); // "Kopfhörer, die die Stadt stummschalten"
t(hero.hero_title.content, 'de', 'en'); // German not filled in yet? → the English textEvery code in ?lang= must be an active language of the site, otherwise you get 400 unknown_lang with the list of available ones. Leave lang out and you get all active languages at once. The whole story is in Languages & fallbacks.
Switch on live editing
Now the fun part. Your page already shows content from the CRM; let's let editors change it right on the page, without hunting for the right field in a form.
Add the widget
One script, once per page, right before
</body>. It loads the editor only when your site is opened inside the CRM, so regular visitors don't download a single byte of it.<script src="https://widget.sitecog.com/widget.js" defer></script>Tell the editor which element shows which block
Add
data-crm-textwith the block marker to the element that renders it. Keep rendering the text from the API as before — the attribute just connects the element with the block.<body> <h1 data-crm-text="hero_title">Earbuds that mute the city</h1> <!-- once per page, right before </body> --> <script src="https://widget.sitecog.com/widget.js" defer></script> </body>// app/layout.tsx export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body> {children} <script src="https://widget.sitecog.com/widget.js" defer /> </body> </html> ); } // app/page.tsx — same render as before, plus one attribute return <h1 data-crm-text="hero_title">{hero.hero_title.content.en}</h1>;Images, videos, objects and arrays have their own attributes (
data-crm-image,data-crm-video,data-crm-object,data-crm-array) — see Widget & markup. The attributes are harmless for visitors, so leave them in production.Allow the CRM to frame your site
Live mode opens your site inside the CRM, in an iframe. Your server has to allow that with
frame-ancestors, and must not sendX-Frame-Options: DENYorSAMEORIGIN. Otherwise the CRM will tell you that the site forbids embedding.// next.config.js module.exports = { async headers() { return [{ source: '/:path*', headers: [ { key: 'Content-Security-Policy', value: "frame-ancestors 'self' https://sitecog.com" }, ], }]; }, };# nginx — and make sure nothing sends X-Frame-Options: DENY or SAMEORIGIN add_header Content-Security-Policy "frame-ancestors 'self' https://sitecog.com";Click, type, save
Open your site in the CRM in Live mode, click the headline, change it and save. The CRM writes the block, the content version goes up, your page fetches fresh content — and the new headline is right there. 🎉
Bonus: if you put
data-crm-text="promo_note"on an element before such a block exists, the editor can create the block right from the site.
Skip the cache for editors
Our side never serves stale content in Live mode. But your cache can: with revalidate: 60 an editor might save and still see the old text for up to a minute. Inside the CRM frame the URL gets ?crm_live=1 — use it to fetch with cache: 'no-store':
// app/page.tsx — skip every cache while an editor is looking
const API = 'https://back.sitecog.com/content';
export default async function Home({ searchParams }: { searchParams: Promise<{ crm_live?: string }> }) {
const live = (await searchParams).crm_live === '1';
const res = await fetch(API + '/v1/pages/home?lang=en', {
headers: { 'x-crm-key': process.env.CRM_KEY! },
...(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>;
}// Inside the CRM frame (or with ?crm_live=1) always ask for fresh content
const live = window.self !== window.top || new URLSearchParams(location.search).has('crm_live');
const res = await fetch('https://back.sitecog.com/content/v1/pages/home?lang=en', {
headers: { 'x-crm-key': 'pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40' },
cache: live ? 'no-store' : 'default',
});What next
You have content flowing and editors clicking. Here is where to go deeper: