JSON Templates
Use JSON templates when KQL can't shape the response you need – custom aggregations, computed fields, or data drawn from outside Kirby.
Global Routes
By default, Kirby Headless does not interfere with Kirby's routing. Enable global routes to automatically return JSON from all templates:
return [
'headless' => [
'globalRoutes' => true
]
];
Multi-Language
A prefixed URL names its own language, so /de/about is served in German. A path without a prefix takes its language from the X-Language header instead, which is how a frontend that keeps its own routing asks for a translation:
await fetch("https://example.com/about", {
headers: {
Authorization: `Bearer ${process.env.KIRBY_API_TOKEN}`,
"X-Language": "de",
},
});
The translation follows the language, so t() in a template returns the same language as the content around it – a template mixing translated labels into its JSON does not answer half in each.
/de/about stays German even with X-Language: en, so a proxy that appends the header to every request cannot overrule a URL. A code that matches no language of the site is ignored, and the request falls back to the default language.Writing JSON Templates
Encode template data as JSON in your template files:
<?php
$data = [
'title' => $page->title()->value(),
'layout' => $page->layout()->toResolvedLayouts()->toArray(),
'address' => $page->address()->value(),
'email' => $page->email()->value(),
'phone' => $page->phone()->value(),
'social' => $page->social()->toStructure()->toArray()
];
echo \Kirby\Data\Json::encode($data);
Shaping the Response
The template receives Kirby's response object, so it can set the status code, add headers, or opt out of the page cache. Whatever the template sets wins – Kirby Headless only fills in what is left, which is application/json as the content type and 200, or 404 on the error page.
<?php
if ($page->isExpired()->toBool()) {
$kirby->response()->code(410);
}
$kirby->response()->header('Cache-Control', 'public, max-age=300');
echo \Kirby\Data\Json::encode(['title' => $page->title()->value()]);
The response configuration is cached alongside the body, so a header set here applies to every visitor and not just the one whose request filled the cache. To keep a response out of the cache entirely, call $kirby->response()->cache(false).
Previewing Drafts
A draft is not public, but it renders for a request that proves it may see it – either a logged-in Panel user with access to the page, or a valid preview token, which is what the Panel's preview button appends:
/blog/unpublished?_token=…&_version=changes
_version=changes renders the unsaved state from the Panel, latest the last saved one. Without either credential a draft answers with the error page, exactly as Kirby does when it renders HTML itself.
Fetching Template Data
Fetch JSON template data with bearer token authentication:
const response = await fetch("https://example.com/about", {
headers: {
Authorization: `Bearer ${process.env.KIRBY_API_TOKEN}`,
},
});
const data = await response.json();
Template Routes
You can also fetch templates by name using the __template__ endpoint. It lives in Kirby's API namespace, so the full path is /api/__template__/…:
const response = await fetch("https://example.com/api/__template__/about", {
headers: {
Authorization: `Bearer ${process.env.KIRBY_API_TOKEN}`,
},
});
const data = await response.json();
This fetches the about template and returns its JSON output. On a multi-language site the translation follows the language, so t() answers in the language of the content around it, exactly as it does for the routes above.
__template__ endpoint wraps the rendered template in the standard API envelope: { "code": 200, "status": "OK", "result": … }. The catch-all route above (e.g. /about) instead returns the template's raw JSON output.Caching
Both __template__ and __sitemap__ answer from Kirby's pages cache when it is enabled. Their cache key is the template name or the endpoint, plus the language on a multi-language site, so a request that carries query or body data is always rendered fresh – the key could not tell those requests apart. Send X-Cacheable: false to bypass the cache for a single request, the same header the KQL endpoint accepts.
Sitemap Endpoint
Kirby Headless includes a built-in sitemap endpoint at /api/__sitemap__ that returns all indexable pages:
const response = await fetch("https://example.com/api/__sitemap__", {
headers: {
Authorization: `Bearer ${process.env.KIRBY_API_TOKEN}`,
},
});
const sitemap = await response.json();
The response wraps the page list in the standard API envelope. On multi-language sites, each entry also gains a links array with one entry per language plus an x-default fallback:
{
"code": 200,
"status": "OK",
"result": [
{
"url": "/blog/first-post",
"modified": "2024-01-15",
"links": [
{ "lang": "en", "url": "/blog/first-post" },
{ "lang": "de", "url": "/de/blog/erster-beitrag" },
{ "lang": "x-default", "url": "/blog/first-post" }
]
}
]
}
modified field is omitted when a page has no resolvable modification date. The links array is only present on multi-language sites, and each lang value is derived from the language's locale (for example en_US.UTF-8 becomes en-us).Sitemap Configuration
Configure which pages appear in the sitemap:
return [
'headless' => [
'sitemap' => [
'exclude' => [
// Exclude pages by template name
'templates' => ['error', 'maintenance'],
// Exclude pages by ID (supports regex patterns)
'pages' => [
'home/draft-page',
'blog/.*-draft$' // Regex: exclude all blog drafts
]
],
// Custom indexability check
'isIndexable' => function ($page) {
// Only include listed pages that are marked as public
return $page->isListed() && $page->isPublic()->toBool();
}
]
]
];
exclude.pages also accepts a callable returning the list, for patterns that only exist at runtime:
return [
'headless' => [
'sitemap' => [
'exclude' => [
'pages' => fn () => site()->find('blog')->children()->filterBy('noindex', true)->keys()
]
]
]
];
Blueprint-Level Control
Kirby's own blueprint options keep a page out of the sitemap, with no config entry of its own:
options:
sitemap: false
For a choice editors make per page, add a field and read it in isIndexable:
fields:
sitemap:
type: toggle
label: Include in Sitemap
default: true
return [
'headless' => [
'sitemap' => [
'isIndexable' => function ($page) {
return $page->sitemap()->toBool();
}
]
]
];
exclude.templates, then exclude.pages, then the blueprint's sitemap option, then isIndexable.