Every text in Diil can exist in several languages, and the API hands them to you as simple maps: { "en": "Hello", "de": "Hallo" }. This guide covers how to ask for exactly the languages you need, what happens when a translation is missing, and how to build a multilingual site that never shows a visitor an empty headline.
The lang parameter: one, several or all languages
Every content endpoint — pages, sections, blocks and the blog — accepts the same lang query parameter. It only trims the translations in the response; the structure of the page stays the same.
langquerystringoptionalDefault: all active languages?lang=en), a comma list (?lang=en,de) or a repeated parameter (?lang=en&lang=de). Up to 50 codes. Each one must be an active language of the site.| You send | You get |
|---|---|
| nothing | Every active language of the site |
?lang=de | German only |
?lang=de,en | German and English |
?lang=de&lang=en | The same thing, for libraries that like repeated parameters |
curl "https://back.sitecog.com/content/v1/pages/home?lang=de,en" \
-H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"const params = new URLSearchParams({ lang: 'de,en' });
const res = await fetch('https://back.sitecog.com/content/v1/pages/home?' + params, {
headers: { 'x-crm-key': 'pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40' },
});
const page = await res.json();Which languages are active? Ask GET /v1/langs. It returns them in the order set in the CRM, and that list is the backbone of everything below.
[
{ "key": "en", "title": "English", "index": 0 },
{ "key": "de", "title": "German", "index": 1 },
{ "key": "uk", "title": "Українська", "index": 2 }
]What language maps look like
Wherever text can be translated, you get an object keyed by language code. Text and html blocks, link titles, page titles and descriptions, blog titles — all the same shape.
"promo_title": {
"type": "text",
"content": { "en": "Free delivery this week", "de": "" }
}Look closely, there are three different situations in one tiny object:
en— translated, all good.de— the editor cleared the German text, so it is an empty string.uk— never filled in at all, so the key is simply not there.
Blog maps are a bit tidier: empty translations are left out there entirely. Either way, treat "" and a missing key as the same thing — “no translation”. All the shapes are on the Block types page.
Key order and ETag stability
Language keys always come back in the order set in the CRM, no matter how you list them in the request. ?lang=de,en and ?lang=en,de return byte-for-byte the same body with the same ETag, so a 304 Not Modified works whichever way you spell it.
Validation errors
The API is strict about lang on purpose: a typo should fail loudly in development, not quietly return an empty page in production. All three errors are 400 with a JSON body.
invalid_lang — the code is malformed
A code must look like en or pt-BR: two lowercase letters, optionally a dash and 2–4 more letters. The check is case-sensitive, so EN does not pass.
{ "message": "invalid_lang", "lang": "EN" }unknown_lang — the language is not active on the site
The code is well-formed, but the site does not have that language, or it is switched off in the CRM. The body helpfully tells you which codes were unknown and which ones you can use.
{
"message": "unknown_lang",
"lang": "fr",
"unknown": ["fr"],
"available": ["en", "de"]
}Case matters here too: if the site language is pt-BR, then pt-br passes the format check but is a different code, and you get unknown_lang.
too_many_langs — more than 50 codes
{ "message": "too_many_langs", "max": 50 }If you ever hit this one, you probably wanted to leave lang out altogether: no parameter means all languages.
There is no server-side fallback
If the German translation is missing, the API does not quietly slip the English one in its place. You get exactly what the editors saved: an empty string or no key at all. That is deliberate — only you know whether your site should show English instead, hide the block, or show a “not translated yet” note.
The flip side: falling back is your job. Luckily it takes about ten lines.
Recommended client-side fallback
We suggest this order, and it is what every example in these docs uses:
The requested language
The visitor is on/de/, so trydefirst.The site default
The first language in/v1/langs— the one the owner put on top in the CRM.Any non-empty translation
Better something than nothing. If even that fails, an empty string.
const API = 'https://back.sitecog.com/content';
export type SiteLang = { key: string; title: string; index: number };
export type LangMap = Record<string, string>;
/** Active languages in CRM order; the first one is the site default */
export async function getLangs(): Promise<SiteLang[]> {
const res = await fetch(API + '/v1/langs', {
headers: { 'x-crm-key': process.env.CRM_KEY! },
next: { revalidate: 60 },
});
if (!res.ok) throw new Error('Content API: ' + res.status);
return res.json();
}
/** Requested language → fallback language → first non-empty translation → '' */
export function t(map: LangMap | null | undefined, lang: string, fallback?: string): string {
if (!map) return '';
const own = map[lang];
if (own) return own;
const backup = fallback ? map[fallback] : '';
if (backup) return backup;
return Object.values(map).find((value) => value !== '') ?? '';
}
/** ?lang= value: the visitor's language plus the default, always in CRM order */
export function langParam(lang: string, langs: SiteLang[]): string {
const fallback = langs[0]?.key;
return langs
.filter((item) => item.key === lang || item.key === fallback)
.map((item) => item.key)
.join(',');
}The same t() powers the universal block renderer, and pickMedia() there applies the very same order to per-language images and videos.
Detecting the visitor's language
The API does not care how you pick a language — it just needs a valid code. Here is a setup that is easy to reason about, friendly to search engines and kind to visitors.
The URL is the source of truth
Put the language into the path: /en/about, /de/about, /uk/about. Every language gets its own address, links can be shared, and search engines index each version separately. The list of valid prefixes is simply /v1/langs.
import { notFound } from 'next/navigation';
import { getLangs, langParam, t } from '@/lib/langs';
export default async function Home({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params;
const langs = await getLangs();
// Never pass a raw URL segment to the API: /fr/ would turn into a 400
if (!langs.some((item) => item.key === lang)) notFound();
const fallback = langs[0].key;
const res = await fetch(
'https://back.sitecog.com/content/v1/pages/home?lang=' + langParam(lang, langs),
{ headers: { 'x-crm-key': process.env.CRM_KEY! }, next: { revalidate: 60 } },
);
const page = await res.json();
const hero = page.content.hero.content;
return <h1>{t(hero.hero_title.content, lang, fallback)}</h1>;
}The bare domain picks a language
Only the root / has to guess. Check, in this order: the language the visitor chose before (a cookie), the browser's Accept-Language, and finally the site default.
/** Best match for the Accept-Language header: 'de-AT,de;q=0.9' → 'de' */
export function langFromHeader(header: string, langs: SiteLang[]): string | null {
const keys = langs.map((item) => item.key);
for (const part of header.split(',')) {
const code = part.split(';')[0].trim();
const base = code.split('-')[0];
if (keys.includes(code)) return code;
if (keys.includes(base)) return base;
}
return null;
}import { cookies, headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { getLangs, langFromHeader } from '@/lib/langs';
export default async function Root() {
const langs = await getLangs();
const saved = (await cookies()).get('lang')?.value;
const accept = (await headers()).get('accept-language') ?? '';
const lang =
langs.find((item) => item.key === saved)?.key ?? // the visitor chose it before
langFromHeader(accept, langs) ?? // the browser's preference
langs[0].key; // the site default
redirect('/' + lang);
}The switcher writes that cookie whenever someone picks a language by hand. Titles come straight from the CRM:
'use client';
import { usePathname } from 'next/navigation';
import type { SiteLang } from '@/lib/langs';
export function LangSwitcher({ langs, current }: { langs: SiteLang[]; current: string }) {
// '/de/about/team' → 'about/team'
const rest = usePathname().split('/').slice(2).join('/');
const remember = (lang: string) => {
document.cookie = 'lang=' + lang + '; path=/; max-age=31536000; samesite=lax';
};
return (
<nav aria-label="Language">
{langs.map((item) => (
<a
key={item.key}
href={'/' + item.key + (rest ? '/' + rest : '')}
aria-current={item.key === current ? 'page' : undefined}
onClick={() => remember(item.key)}
>
{item.title}
</a>
))}
</nav>
);
}SEO: hreflang for every language
Tell search engines that /en/about and /de/about are the same page in different languages. Then a German searcher lands on the German version, and the two pages are not treated as duplicates. Build the tags from /v1/langs, so a language added in the CRM shows up here automatically.
<html lang="de">
<head>
<link rel="alternate" hreflang="en" href="https://example.com/en/about" />
<link rel="alternate" hreflang="de" href="https://example.com/de/about" />
<link rel="alternate" hreflang="uk" href="https://example.com/uk/about" />
<link rel="alternate" hreflang="x-default" href="https://example.com/en/about" />
</head>// Codes the owner may have typed differently from ISO 639-1
const HREFLANG_FIX: Record<string, string> = { ua: 'uk' };
export function alternates(path: string, langs: SiteLang[], origin: string) {
const links = langs.map((item) => ({
hreflang: HREFLANG_FIX[item.key] ?? item.key,
href: origin + '/' + item.key + path,
}));
if (langs[0]) links.push({ hreflang: 'x-default', href: origin + '/' + langs[0].key + path });
return links;
}- Every language version lists all versions, including itself.
x-defaultis the version for everyone else; the site default language is a sensible choice.- In Next.js the same list goes into
alternates.languagesofgenerateMetadata. - Set
<html lang>to the current language too — screen readers and translation tools rely on it.
Language codes, and a note about Ukrainian
The API uses exactly the codes the site owner configured in the CRM. Two lowercase letters, optionally a region: en, de, pt-BR. Don't hard-code the list on your side — read it from /v1/langs, and a new language becomes a CRM setting rather than a deploy.