Diil Docs
  1. Docs
  2. Guides

Languages, the lang parameter and fallbacks

Updated:

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
One code (?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 sendYou get
nothingEvery active language of the site
?lang=deGerman only
?lang=de,enGerman and English
?lang=de&lang=enThe 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"

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.

GET /v1/langsjson
[
  { "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.

A text block, ?lang=en,de,ukjson
"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.

GET /v1/pages/home?lang=EN → 400json
{ "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.

GET /v1/pages/home?lang=fr → 400json
{
  "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

400json
{ "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:

  1. The requested language

    The visitor is on /de/, so try de first.
  2. The site default

    The first language in /v1/langs — the one the owner put on top in the CRM.
  3. Any non-empty translation

    Better something than nothing. If even that fails, an empty string.
lib/langs.tsts
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.

app/[lang]/page.tsxtsx
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.

lib/langs.ts (continued)ts
/** 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;
}
app/page.tsxtsx
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:

components/LangSwitcher.tsxtsx
'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.

In the <head> of /de/abouthtml
<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>
lib/langs.ts (continued)ts
// 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-default is the version for everyone else; the site default language is a sensible choice.
  • In Next.js the same list goes into alternates.languages of generateMetadata.
  • 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.