Pages
Pages form trees. Each root page (parent_id: null, path /) is a separate website; which one is served is decided by the requested domain (domains), otherwise the default website (default_site). Root pages cannot be moved; any page can be deleted, even the last root page (along with its pages). An instance without a website – which is how a new binary starts – returns 404 on every path. A page’s path is made up of the slugs of its ancestors: /produkte/schuhe/sneaker. Paths are only unique within a website – root_id tells you which website a page belongs to.
Page object
{
"id": 2,
"parent_id": 1,
"slug": "blog",
"title": "Blog",
"html": "<main>{% for p in data(\"blog\") %}…{% endfor %}</main>",
"head": "<meta property=\"og:title\" content=\"{{ page.title }}\">",
"description": "Neuigkeiten",
"meta_title": "{% if route.slug %}{{ item(\"blog\", \"slug\", route.slug).titel }} – {% endif %}Blog",
"html_attrs": "",
"lang": "de",
"effective_lang": "de",
"translation_group": "k3j9q2xw7m",
"body_attrs": "class=\"blog\"",
"published": true,
"catch_all": true,
"no_cache": false,
"sitemap_exclude": false,
"noindex": false,
"nofollow": false,
"hide_in_nav": false,
"source_url": "https://www.firma.de/blog",
"import_root": 1,
"local_modified": false,
"root_id": 1,
"domains": "",
"default_site": false,
"notfound_page_id": 0,
"favicon_file_id": 0,
"og_image_file_id": 0,
"robots_txt": "",
"sort": 0,
"created_at": 1791191665,
"updated_at": 1791191700,
"path": "/blog",
"assets": [
{ "asset_id": 1, "inherit": true, "name": "base", "kind": "css" },
{ "asset_id": 4, "inherit": false, "name": "blog", "kind": "js" }
],
"blocks": []
}
| Field | Type | Description |
|---|---|---|
parent_id |
int | null | Parent page; null only for the home page |
slug |
string | URL segment: a-z, 0-9, ., _, -; must start with a letter/digit. Unique among siblings. Home page: always "" |
title |
string | Required; name of the page in the page tree, in nav()/children() and default for <title> |
meta_title |
string | Optional SEO title for <title>, evaluated as a template (e.g. article title on catch-all pages). Empty = title. Available in the template as page.meta_title |
html |
string | Page content as a pongo2 template. Inserted into <body>. If it contains an <html> element itself, it is used unchanged as the whole document, and CSS/JS are inserted before </head> and </body> respectively |
head |
string | Additional template for <head> |
description |
string | Output as <meta name="description">; template syntax allowed |
html_attrs |
string | Attributes of the <html> element, output raw (not a template). Without lang=, IllusionCMS adds lang="<effective_lang>"; an own lang= wins. Webflow imports set e.g. data-wf-page/data-wf-site here |
lang |
string | Own language of the page (BCP 47, normalized to lower case, e.g. de, en, de-at); empty = inherited from the nearest page above. PUT without the field = unchanged. Invalid: 400 translation.langInvalid. See language versions |
effective_lang |
string | Read-only: effective language (own, inherited or de) – page.lang in templates |
translation_group |
string | Read-only: translation group (empty = none); changed via /pages/{id}/translations |
body_attrs |
string | Attributes of the <body> element, output raw |
published |
bool | Unpublished pages (drafts) return 404 in the frontend (except for admins with ?preview=1). A draft hides only itself – its subpages remain reachable, e.g. a folder /blog without its own page above /blog/mein-artikel |
catch_all |
bool | The page also answers non-existent subpaths. The rest is available in the template as route.slug / route.rest – ideal for detail pages. If the entry doesn’t exist, {{ not_found() }} returns the 404 page |
no_cache |
bool | Never keep the page in the page cache, render it anew on every request |
sitemap_exclude |
bool | Don’t list the page in /sitemap.xml (e.g. thank-you pages, landing pages) |
noindex |
bool | Outputs <meta name="robots" content="noindex">; the page is also left out of /sitemap.xml |
nofollow |
bool | Outputs <meta name="robots" content="nofollow"> (together with noindex: noindex, nofollow) |
sitemap_source |
object | null | Catch-all only: source of the detail pages for /sitemap.xml and the static export. null/missing = automatic (collections via item(…, route.slug)). IllusionDB: { "type": "illusiondb", "connection": "shop", "table": "landingpages", "column": "slug", "lastmod": "geaendert", "view": "", "rules": [{ "col": "aktiv", "op": "eq", "val": "true" }], "match": "and" } (rules/op as in the workflow node, without placeholders; view = saved view as a filter). Workflow: { "type": "workflow", "path": "orte", "column": "slug", "lastmod": "" } – the GET input receives sitemap=1 and returns a list of objects or strings (or { "records": [...] }). column defaults to slug, values with / produce multi-level paths; lastmod optional (ISO date or Unix time). For IllusionDB, only these two columns are queried (?fields=). Invalid: 400 |
hide_in_nav |
bool | The page is left out of the template functions nav()/children() (included with nav(true)/children(id, true)), but stays reachable and in the sitemap |
source_url |
string | Read-only: origin of imported pages |
import_root |
int | Read-only: target page of the import the page comes from |
root_id |
int | Read-only: website (root page) the page belongs to |
renders_blocks |
bool | Read-only, only in the response for a single page (GET/POST/PUT /pages/{id}): does the page’s template, including extends/include, output a block area ({% blocks page.blocks %})? Static analysis like has_blocks of the page layouts. The editor uses it for the hint in the Blocks tab |
domains |
string | Root pages only: domains of the website, one per line. Normalized (lowercase, without scheme/port/path); a domain belongs to at most one website (409) |
default_site |
bool | Root pages only: default website for all domains without their own website. Setting it to true removes the flag from other websites. Without a flag, the oldest website applies |
notfound_page_id |
int | Root pages only: page of this website that is rendered as the 404 page (0 = automatic: page with slug 404 directly below the home page). Must be a subpage of the same website, otherwise 400; ignored on subpages. If the page is a draft or deleted, the automatic choice applies |
og_image_file_id |
int | Preview image for link previews (og:image) – ID of an image file, 0 = that of the nearest page above. Every page automatically gets Open Graph and twitter:card tags (title, description, absolute URL via the website’s first domain or the request); tags already present in the head field/HTML are not output twice. Not an image file: 400 |
favicon_file_id |
int | Root pages only: website icon – ID of an image file from file management (SVG, PNG, ICO …; 0 = none). Every page of the website gets <link rel="icon"> (large PNG/JPEG from 120 px additionally apple-touch-icon), /favicon.ico serves the file. Not an image file: 400; ignored on subpages |
robots_txt |
string | Root pages only: custom content for /robots.txt. Empty = automatic (User-agent: * / Allow: /). If a Sitemap: line is missing, it is added with the requested domain |
local_modified |
bool | Read-only: the page was edited in the CMS after the import (comparison of the content with the imported version; for pages never imported: edited at all) |
sort |
int | Position among siblings (set via /move) |
path |
string | Computed URL path (read-only) |
assets |
array | CSS/JS assignments in loading order (see below) |
blocks |
array | Page built from blocks (block tree), output in the template via {% blocks page.blocks %}. Without blocks []; missing in GET /pages (list without content). PUT without blocks leaves the tree unchanged, otherwise it is validated and normalized (400 with block.* codes). Versioned and part of the bundle |
Reserved slugs at the top level (directly below the home page): cms-admin, api, media, _assets, sitemap.xml.
Asset assignments
| Field | Type | Description |
|---|---|---|
asset_id |
int | ID of a CSS/JS asset |
inherit |
bool | true = also loaded on all subpages |
name, kind |
string | Read-only, for display |
When rendering, the inherited assets of the ancestors (from the home page downward) are loaded first, then the page’s own; duplicates are removed. CSS goes into <head>, JS at the end of <body>.
GET /pages 🔑
All pages as a flat list, sorted by parent and sort – without html and head (for tree views). assets is included. You build the tree via parent_id.
With ?roots=1, only the root pages are returned, i.e. the websites.
GET /pages/{id} 🔑
A page including html, head and assets.
POST /pages ✏️
Creates a subpage (at the end of the sibling list).
{
"parent_id": 1,
"title": "Über uns",
"slug": "ueber-uns",
"html": "<main><h1>{{ page.title }}</h1></main>",
"published": true,
"assets": [{ "asset_id": 1, "inherit": true }]
}
Required: parent_id, title, slug. Response 201 with the complete page object.
With layout_id instead of html the page is created from a page layout: its HTML is then {% extends "<layout name>" %} (also in a batch under data). The server rejects layout_id together with a non-empty html (400 page.layoutWithHTML) and a snippet without the page layout flag (400 page.layoutUnknown).
Errors: 400 invalid/reserved slug, 404 parent page missing, 409 slug already taken among siblings.
PUT /pages/{id} ✏️
Merge update. If assets is included in the body, the asset assignments are completely replaced; if the field is missing, they remain unchanged. parent_id and sort are ignored here – use /move to move pages.
{ "title": "Über uns", "html": "<main>…</main>", "published": true }
Response 200 with the updated object.
DELETE /pages/{id} ✏️
Deletes the page including all subpages. For a root page, the entire website is deleted – even the last one. 204.
POST /sites ✏️
Creates a new website (root page with default HTML, published).
{ "title": "Shop", "domains": "shop.firma.de\nwww.shop.firma.de" }
Response 201 with the page object of the new root page. 409 if a domain already belongs to another website.
GET /sites/{id}/static 🔑
Exports the website {id} (root page) as a static website: finished HTML files with CSS, JS and media as a ZIP – to unpack into the document root of a web server such as Apache.
- Pages: All published pages of the tree are rendered. In addition, there are the detail pages behind catch-all pages that also appear in the sitemap (collections or
sitemap_source, e.g. thousands of landing pages from IllusionDB). The export also follows all internal links (<a href>), so other linked detail pages are included as well (e.g./blog/mein-artikel). Each page becomes<pfad>/index.html(/→index.html); paths with a file extension such as/feed.xmlremain files. Apache then redirects/pfadto/pfad/. - Other files:
404.html(the website’s 404 page),.htaccess(ErrorDocument 404 /404.html, no directory listings, the website’s redirects asRedirectMatch– mod_alias is sufficient),robots.txt, all CSS/JS assets under_assets/…,sitemap.xml(if a base URL is known; with more than 50,000 addresses as an index withsitemap-1.xml,sitemap-2.xml, …) and the media undermedia/…. - URLs remain unchanged (absolute paths from
/) – the export belongs in the document root of the domain, not in a subfolder. - Query variants (
?seite=2), forms posting to the CMS,/api/v1/…calls from JavaScript and REST data after the export don’t work statically; REST data is frozen at the state of the export.
| Query | Meaning |
|---|---|
dry=1 |
Check only: returns the report as JSON instead of the ZIP |
media=all |
All files from media management; default: only those that appear in the rendered pages or in CSS/JS (including srcset variants) |
base |
Base URL for sitemap.xml, e.g. https://www.firma.de. Empty = first domain of the website with https://; without a domain, no sitemap |
Report (dry=1):
{
"pages": ["/", "/blog", "/blog/hallo", "/kontakt"],
"errors": {},
"broken_links": { "/kaputt": "/" },
"media_files": 29, "media_bytes": 7900000, "missing_media": [],
"asset_files": 11, "base": "https://www.firma.de", "too_many": false,
"private_media": 0
}
errors: pages that cannot be rendered – in that case the export without dry returns 422 with { "error", "report" } instead of an incomplete ZIP. broken_links: internal links without a target (path → page containing the link). missing_media: files that are used but don’t exist. private_media: left-out files of private media (original, variants, WebP) – private files are always missing, also with media=all. Beyond 20,000 pages, link following stops (too_many).
PUT /pages/{id}/assets ✏️
Replaces only the asset assignments. The body is an array in loading order:
[
{ "asset_id": 1, "inherit": true },
{ "asset_id": 4, "inherit": false }
]
Response 200 with the page object.
POST /pages/{id}/move ✏️
Moves a page within the tree.
{ "parent_id": 5, "index": 0 }
| Field | Description |
|---|---|
parent_id |
New parent page |
index |
Position among the new siblings (0 = first). Invalid values (< 0 or too large) append to the end |
Response 200: the complete page list as with GET /pages.
Errors: 400 moving the home page, moving into its own subtree, reserved slug at the top level; 409 slug already taken among the new siblings.
POST /pages/{id}/preview 🔑
Renders a page with unsaved content – used for the live preview in the admin. All fields are optional; missing ones are taken from the saved page.
{
"html": "<h1>Entwurf</h1>",
"head": "",
"title": "Neuer Titel",
"description": "",
"assets": [{ "asset_id": 1, "inherit": true }],
"overrides": { "1": "body { background: hotpink }" },
"query": { "seite": "2" },
"rest": ["mein-artikel"],
"blocks": [{ "id": "b1", "type": "hero", "params": { "titel": "Ungespeichert" } }],
"item": { "collection": "blog", "id": 12, "data": { "titel": "Ungespeichert", "slug": "mein-artikel" } }
}
| Field | Description |
|---|---|
assets |
Unsaved asset assignments of this page |
overrides |
Unsaved content of assets (asset_id → content) |
query |
Simulated query parameters (route.query) |
rest |
Simulated subpath for catch-all pages (route.rest) |
blocks |
Unsaved block tree of the page (page.blocks) – not validated; unknown block types appear as a notice box |
item |
Unsaved collection entry: replaces the entry id (or is added as new with id: 0) in data()/item() – for the preview while editing an entry on its detail page. Blocks fields in it are rendered like stored ones |
In the preview, all CSS/JS assets are output inline, and unpublished collection entries are taken into account as well; blocks carry preview markers. Addresses of private files from image()/file() (also the site icon and og:image) are signed (?exp=…&sig=…, valid for one to two hours) so that they load in the iframe without cookies – likewise in preview links and with ?preview=1, never on live pages.
Response: 200 text/html with the finished document; on template errors 422 text/html with an error page. If the template calls not_found(), the response is 200 with a notice (for catch-all pages with existing subpaths).
POST /pages/{id}/sitemap-preview ✏️
Checks a (possibly unsaved) sitemap source of the page: body { "sitemap_source": { … } }, response { "count": 3920, "sample": [{ "path": "/leistungen/berlin", "lastmod": "2026-09-02" }, …], "duration_ms": 46 } (the first 10). Errors from the source (IllusionDB unreachable, workflow missing): 502. Does not clear the page cache.
POST /pages/{id}/template-data 🔑
Returns what a template of the page sees – for the Template data panel in the editor. Optionally with unsaved title, meta_title, description as well as query and rest as in the preview; computed as in the preview (drafts visible). Does not clear the page cache.
{
"variables": { "page": { "id": 2, "title": "Blog", "meta_title": "Blog | Firma", … }, "route": { … }, "site": { … }, "today": "2026-10-08", "now": "2026-10-08T14:03:00+02:00", "preview": true },
"nav": [{ "id": 2, "title": "Blog", "path": "/blog", … }],
"children": [],
"collections": [{ "slug": "blog", "name": "Blog", "fields": [{ "name": "titel", "type": "text" }], "count": 42, "items": [{ "id": 1, "titel": "…", "_created": 1791457748, "_updated": 1791457748 }] }],
"sources": [{ "slug": "wetter", "name": "Wetter", "url": "https://…" }]
}
variables contains only values, no functions. items shows the first 20 entries as data("slug") returns them, count the total number. REST sources are not fetched (the admin fetches them on click via GET /data/{slug}). Response 422 on errors in the SEO title or the description.
POST /pages/{id}/reimport ✏️
“Update from source”: reloads an imported page from source_url – with the download hosts of the last import of this source into its website (without a saved profile: derived from the hosts of the files already imported from the source). The page is written even if the source hasn’t changed; files and CSS/JS are reused as in a re-import.
{ "overwrite_modified": true }
overwrite_modified is required if the page was edited in the CMS (local_modified: true) – otherwise it stays unchanged. Response 202 with an import job.
GET /pages/{id}/render 🔑
Renders the saved page and returns the HTML as JSON – handy for headless scenarios or static exports. Query parameters of the request are passed through as route.query.
{ "html": "<!doctype html>…", "path": "/blog" }
422 with { "error": "HTML: … line 3 …" } on template errors.
Preview links ✏️
GET /pages/{id}/preview-links – valid links of the page. POST /pages/{id}/preview-links with { "days": 7 } (1–90, default 7) creates one (201). DELETE /preview-links/{id} revokes it (204).
{ "id": 3, "page_id": 12, "token": "9faf061b27c6…", "author": "erik", "created_at": 1791368754, "expires_at": 1791973554 }
Usage: <Seitenpfad>?ic_preview=<token> – renders the link’s page or one below it (including catch-all details) with drafts (pages and collection entries), without login, Cache-Control: no-store, X-Robots-Tag: noindex, nofollow, without statistics. The website is determined by the link, not by the domain. Invalid, expired or another page: 404. Links belong to the instance (not in the bundle).
GET /pages/{id}/mhtml 🔑
The saved page as an MHTML archive (multipart/related, downloaded as <slug>-<datum>.mhtml) – page plus stylesheets, images, fonts and icons in a single file, including from third-party hosts. Scripts are not packed (browsers don’t run them in MHTML); srcset/sizes/loading and <picture><source> are removed. The references in the HTML remain unchanged; they are resolved via the Content-Location of the parts – the base is the website’s first domain (https://…), otherwise the address of the request.
Limits: 25 MB per file, 250 MB in total, 1,000 parts. Anything that doesn’t fit or can’t be fetched is missing from the archive. The response headers X-IC-MHTML-Parts and X-IC-MHTML-Skipped give the number of packed and skipped files.
curl -OJ "https://example.com/api/v1/pages/12/mhtml?token=$TOKEN"
422 on template errors.
Language versions
Multilingual websites consist of separate subtrees (/en/…) or separate websites (firma.de ↔ firma.com). To tell search engines and language switchers which pages contain the same content in another language, you link them into a translation group.
- Language of a page: field
lang(own language, BCP 47, e.g.de,en,de-at; stored in lower case, invalid →400 translation.langInvalid). The effective languageeffective_langis the page’s own language or that of the nearest page above withlang, otherwisede. It is written to<html lang="…">(unlesshtml_attrssetslang=itself) and available in templates aspage.lang. - Group rules: a group contains only pages or only entries, at most one member per language (for pages: the effective language). Pages of different websites may share a group. Changing the language or moving a page in a way that would duplicate a language in a group (of the page or a subpage) returns
409 translation.langTaken; so does restoring a version with a different language. - Output: if the rendered page has a group with at least two published members, IllusionCMS writes
<link rel="alternate" hreflang="<language>" href="<absolute URL>">per member into the<head>(including the page itself) plushreflang="x-default"for the member in the language of its own website if all members are in the same website (language subtrees), otherwise in the language of the default website. Absolute URLs use the first domain of the respective website, for the page’s own website the request as a fallback (as with Open Graph); members without an absolute URL are left out. If the head field or HTML already contains a<link … hreflang>, the automatic output is skipped. Catch-all detail pages use the group of the entry shown (detected viaitem(…, route.slug)or the collection’s detail page)./sitemap.xmladds the same information as<xhtml:link>(also in the sitemap index and the static export). translation_groupis read-only on pages and entries;PUTignores it. New pages/entries (also from CSV import) have no group. Groups are included in bundles. Deleting cleans up: groups with only one member left are cleared.
Language version object (response of all endpoints, sorted by language code, including the requested object with current: true):
[
{ "id": 3, "lang": "de", "title": "Kontakt", "path": "/kontakt", "site_id": 1, "url": "/kontakt", "published": true, "current": true },
{ "id": 9, "lang": "en", "title": "Contact", "path": "/contact", "site_id": 4, "url": "https://www.company.com/contact", "published": true, "current": false }
]
| Field | Description |
|---|---|
lang |
Effective language of the page or language of the collection |
path, site_id |
Path within the website and website (root page); for entries the collection’s detail page, without a detail page ""/0 |
url |
Absolute via the website’s first domain, without a domain = path |
collection |
Entries only: slug of the collection |
| Endpoint | Access | Description |
|---|---|---|
GET /pages/{id}/translations |
🔑 | Language versions of the page (without a group only the page itself) |
POST /pages/{id}/translations |
✏️ | Body { "page_id": 9 } – adds page 9 to the group of {id} (creating it if needed). With "move": true, page 9 leaves its previous group. Response 200 with the list |
DELETE /pages/{id}/translations |
✏️ | Removes the page from its group (if only one member is left, its group is cleared as well). Response 200 with the list |
| Code | Status | Meaning |
|---|---|---|
translation.langInvalid |
400 | Invalid language code (lang) |
translation.targetRequired |
400 | page_id or item_id missing |
translation.sameLang |
400 | Same language as the object itself (lang) |
translation.alreadyLinked |
409 | Target is already in another group (title) – move it with "move": true |
translation.langTaken |
409 | The group already has a member in this language (lang) |
translation.noLang |
400 | Entries: collection without a language (collection) |
translation.collectionLinked |
409 | Removing the language of a collection whose entries are linked (count = groups) |