JSON Templates

Return JSON from Kirby templates for full control over your API responses.

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:

config.php
return [
    'headless' => [
        'globalRoutes' => true
    ]
];
Enabling global routes overrides Kirby's default routing – every page is served as JSON instead of HTML.
The catch-all route validates the bearer token before it resolves anything. See What the Token Protects for what is covered and what stays public.

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.

The path wins where both name a language – /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.
This needs one language at the site root, which is Kirby's default setup. Where every language carries a URL prefix, Kirby redirects an unprefixed path to the default language before the header is ever read.

Writing JSON Templates

Encode template data as JSON in your template files:

site/templates/about.php
<?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.

site/templates/article.php
<?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.

The __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" }
      ]
    }
  ]
}
The 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:

config.php
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:

config.php
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:

site/blueprints/pages/landing.yml
options:
  sitemap: false

For a choice editors make per page, add a field and read it in isIndexable:

site/blueprints/pages/default.yml
fields:
  sitemap:
    type: toggle
    label: Include in Sitemap
    default: true
config.php
return [
    'headless' => [
        'sitemap' => [
            'isIndexable' => function ($page) {
                return $page->sitemap()->toBool();
            }
        ]
    ]
];
The four filters are cumulative, and the first one that rejects a page wins: exclude.templates, then exclude.pages, then the blueprint's sitemap option, then isIndexable.