Kwisple
Browse the docs

Content model

Localisation and URIs

Locales per site, translatable versus shared fields, URI formats and slugs, structures, automatic redirects when a URI changes, and how the delivery API answers per locale.

A site has one or more locales; every entry exists in each of them with its own title, slug, URI, enabled flag, SEO values and translatable field values. Shared field values are stored once. URIs are rendered from a format per section, kept unique per locale, and every change of a live URI leaves a redirect behind. This page explains the model behind it and what your site code has to do.

Locales per site

The locales of a site are site settings, set in the dashboard under Website → Instellingen → Algemeen: Talen van de website (languages of the website) and Hoofdtaal (main language). Allowed values are nl, fr, en and de (localeSchema in @kwisple/core); the default is ["nl"] with nl as default locale. The site repository reads them from the delivery API:

const site = await client.site();
site.locales;        // ["nl", "fr"]
site.defaultLocale;  // "nl"

Public paths follow one fixed rule: the default locale lives at the root (/over-ons), every other locale under its prefix (/fr/a-propos). The platform computes these paths (localePrefix in @kwisple/content) and puts them in entry.url, TreeNode.url and in the href of entry links; there is no setting to change the prefix scheme. Your routes only have to read the prefix back, which client.entryByPath(path) does for you.

Adding a locale later is safe: entries get a content row for the new locale with empty values and enabled: false, so nothing goes online until someone fills it in and publishes.

Translatable versus shared fields

Every field has translation: "site" (one value per locale) or translation: "none" (one value for all locales). The defaults by kind:

translation: "site" by defaulttranslation: "none" by default
plainText, richText, link, matrix, tablenumber, toggle, select, date, color, assets, entries, email, url, json

Override per field:

authorName: f.plainText({ label: "Auteur", translation: "none" }),   // same author everywhere
image: f.assets({ label: "Beeld", kinds: ["image"], translation: "site" }), // a photo with text on it

The title is translatable unless the entry type says title: { translation: "none" }.

Where values live (splitByTranslation decides on every save):

  • shared values on the entry (cms_entry.fields), translatable values per locale (cms_entry_content.fields), and for globals a row with locale * next to one per locale;
  • a matrix field with translation: "site" is a whole block list per locale; with "none" one list for all locales (block texts inside are then shared too, whatever their own translation).

In the editor, a multilingual site shows a Taal (language) switch at the top and a globe icon (Per taal in te vullen) next to translatable fields; shared fields show the same value in every language. Required fields are checked per enabled locale; an error in another locale is listed under Ook iets op te lossen in:.

Hiding an entry per locale

Each locale has its own enabled flag: Verbergen van de website (hide from the website) in Pagina-instellingen, shown per language as Verbergen in het Frans on a multilingual site. An entry disabled in fr is not returned for locale=fr, has no URL there and is not checked for required fields in that locale. It keeps its stored values.

Changing translation on an existing field

Switching a field between "site" and "none" moves where its value is read from. The diff reports it as destructive (Veld "intro" in entry type "page": vertaalbaarheid wijzigt (site → none).) and kwisple push needs --force. Content is not deleted, but a field that becomes shared shows the default locale's value everywhere on the next publish, and a field that becomes translatable starts with the shared value in each locale. Plan it, and tell the editors.

URI formats

The uri of a section is a format string rendered per entry and per locale (renderUri in packages/cms/src/uri.ts):

TokenValue
{slug}The entry's slug in that locale.
{parent.uri}The parent's URI (structures); empty for top-level entries.
{postDate.year}Four-digit year of the publish date (UTC).
{postDate.month}Two-digit month of the publish date (UTC).
{locale}The locale code. Rarely needed: the public prefix is added anyway.

Leading, trailing and double slashes are removed after rendering, so {parent.uri}/{slug} gives over-ons for a top-level page and over-ons/team below it.

uriMeaning
"__home__"The homepage. Only for a single. uriToPath("__home__") is /.
"contact"A fixed URI (singles must be fixed: no tokens).
"nieuws/{slug}"A channel under a fixed prefix.
"blog/{postDate.year}/{postDate.month}/{slug}"Dated archive.
"{parent.uri}/{slug}"A structure that mirrors its tree.
{ nl: "blog/{slug}", fr: "blogue/{slug}" }A format per locale. A locale that is missing from the object falls back to the first format.
null or omittedNo URL: entries exist only as data (team members, testimonials, venues). The section then has no Zoekmachines tab or preview.

URIs never carry the locale prefix; entry.uri is a-propos and entry.url is /fr/a-propos. Two helpers convert between a URI and a path without prefix:

import { pathToUri, uriToPath } from "@kwisple/sdk";

uriToPath("over-ons");    // "/over-ons"
uriToPath("__home__");    // "/"
uriToPath(null);          // null  (no URL)
pathToUri("/over-ons/");  // "over-ons"  (query and hash stripped, decoded)
pathToUri("/");           // "__home__"

A fixed URI that is also used by another entry in the same locale is a schema problem, not something the engine numbers away: singles keep their URI even when it clashes.

A changed URI format

Changing a section's uri is reported as a non-destructive change (URI-formaat van "news" wijzigt; bestaande URL's worden herberekend en krijgen automatisch een redirect.). The new format is applied to an entry when it is next published or moved (that is when URIs are rendered); the old URL then gets a redirect as described below. Until then existing entries keep their stored URI.

Slugs

The slug is the {slug} part. In Pagina-instellingen the editor shows the Webadres (web address) previewed from the format, and Laatste deel van het webadres (last part of the web address) with the help text Laat leeg om het adres uit de titel te maken. (leave empty to derive it from the title). On publish:

  1. the typed slug, or else the title, is slugified (slugify from @kwisple/core); an entry with neither gets the last six characters of its id;
  2. the URI is rendered; when another entry of the site already has that URI in that locale the slug gets -2, -3, … (up to 99 tries, then Kon geen unieke URL maken.);
  3. the final slug is stored back and recorded in the revision, so restoring a revision restores the same URLs.

Slugs are per locale, so the French page can be a-propos while the Dutch one is over-ons.

Structures and trees

In a structure section every entry has a parentId, a level (1 = top) and a sort order. With {parent.uri} in the format the tree is visible in the URL. The dashboard tree (Pagina's) supports dragging, Subpagina toevoegen, Onder de vorige pagina zetten, Een niveau hoger zetten and Valt onder in page settings. maxLevels is enforced on create and move: Maximaal 3 niveaus in "Pagina's".; an entry cannot be moved under itself or under one of its descendants.

Moving an entry recomputes the URI of the entry and of its whole subtree, in every locale, and creates redirects for every live URL that changed (Verplaatst. Oude webadressen sturen bezoekers vanzelf door.). Children that were never published have no slug yet and get their URI on their first publish.

For menus use the tree endpoint:

const nodes = await client.tree("pages", { locale: "fr", maxLevel: 2 });
// TreeNode: { id, section, type, title, slug, uri, url, children }

tree() returns live entries only, nested by parent and ordered by sort order; url includes the locale prefix. Queries can also select a level (client.entries({ section: "pages", level: 1 })), the top level (parentId: null) or the children of a page (parentId: page.id), and orderBy: "structure" sorts by level and position.

Redirects when a URI changes

Every change of a live URI creates a permanent redirect, automatically and inside the same transaction as the publish or move (autoRedirect in packages/content/src/write.ts):

  • from the old public path to the new one, both with the locale prefix (/fr/ancien → /fr/nouveau), status 301, exact match, marked automatisch aangemaakt;
  • redirects that pointed at the old path are re-pointed at the new one, so there are never chains;
  • a redirect that would now shadow the new path is deleted;
  • descendants whose URI contains {parent.uri} are recomputed and get their own redirects.

The editor reports it: Het oude webadres stuurt bezoekers vanzelf door. Agency staff see and manage all redirects under Doorverwijzingen (redirects), where they can also add their own: Oud adres, Nieuw adres (a path or a full URL), kind Blijvend (301), Tijdelijk (302) or Verdwenen (410), and Ook alles eronder doorverwijzen (prefix match: /oud/a → /nieuw/a). The 404 log (Niet-gevonden pagina's) offers Doorverwijzen per missed path.

Your site asks the platform when it has no entry for a path:

// Next.js: in the catch-all route when entryByPath() finds nothing
return handleNotFound(api, path, { report: false });   // follows a redirect, else 404 (+ log)

// Astro: resolveEntry() does it
const r = await resolveEntry(Astro, client);
if (r.response) return r.response;                    // 301/302 redirect or 410

client.matchRedirect(path) returns { to, statusCode } | null; exact matches win over the longest matching prefix, and the hit counter in the dashboard is incremented. Matches are cached by the SDK for five minutes, and purged with the site tag when a publish creates one. The Next.js helper withRedirects(client, req) checks redirects before rendering, for sites that prefer that over the Retour-style check on 404.

How the delivery API answers per locale

Every read takes a locale; without it the site's default locale is used.

CallLocale handling
client.entries({ section, locale })Entries that are enabled in that locale, with that locale's title, slug, URI, SEO and translatable values; shared values merged in. url carries the prefix.
client.entry({ uri, locale })URIs are matched per locale: a-propos exists in fr only.
client.entryByPath("/fr/a-propos")Splits the prefix using site().locales and defaultLocale, then calls entry({ uri, locale }). Returns { entry, locale, uri }, also when nothing matches, so you know which locale the 404 page should use.
client.tree(section, { locale })Live entries of that locale with prefixed urls.
client.globals(locale)Shared values (*) merged with that locale's values.
Relations and linksResolved in the locale of the entry being read: an EntryRef carries that locale's title and URI, an entry Link.href that locale's prefixed path. A target that is disabled in that locale is dropped.

Each resolved entry carries entry.locale. To offer language switching you need the same entry in another locale: query by id, client.entry({ id: entry.id, locale: "fr" }), and use its url (or null when it is hidden there). The SEO helpers take the result as alternates (hreflang → absolute URL) for metadataFor / <Seo> and createSitemap; see SEO, performance and security headers.

const { entry, locale } = await api.entryByPath(path);
const alternates: Record<string, string> = {};
for (const l of site.locales) {
  const other = l === locale ? entry : await api.entry({ id: entry.id, locale: l });
  if (other?.url) alternates[l] = `${site.siteUrl}${other.url}`;
}

The dashboard's live preview opens the previewed locale's URL, so the preview route needs no locale logic of its own.

Checklist for a multilingual site

  • Set the languages and the main language in the dashboard before editors start; add per-locale URI formats where the URL should be translated.
  • Decide per field what is shared (translation: "none"): numbers, dates, relations, images without text, technical names.
  • Route every path through entryByPath / resolveEntry; never strip prefixes yourself.
  • Use entry.url, TreeNode.url and Link.href as given; they already carry the prefix.
  • Build alternates from the other locales' urls for hreflang, canonical and the sitemap.
  • Let the platform handle redirects; call handleNotFound / resolveEntry on misses.

Last updated 2026-10-30