---
title: "JSON Templates"
description: "Return JSON from Kirby templates for full control over your API responses."
canonical_url: "https://kirby.tools/docs/headless/usage/json-templates"
---

# 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:

```php [config.php]
return [
    'headless' => [
        'globalRoutes' => true
    ]
];
```

<note>

Enabling global routes overrides Kirby's default routing – every page is served as JSON instead of HTML.

</note>

<note>

The catch-all route validates the bearer token before it resolves anything. See [What the Token Protects](/docs/headless/configuration/authentication#what-the-token-protects) for what is covered and what stays public.

</note>

### 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:

```ts
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.

<note>

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.

</note>

<note>

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.

</note>

## Writing JSON Templates

Encode template data as JSON in your template files:

```php [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.

```php [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:

```text
/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:

```ts
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__/…`:

```ts
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.

<note>

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.

</note>

### 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](/docs/headless/usage/kql) accepts.

## Sitemap Endpoint

Kirby Headless includes a built-in sitemap endpoint at `/api/__sitemap__` that returns all indexable pages:

```ts
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:

```json
{
  "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" }
      ]
    }
  ]
}
```

<note>

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`).

</note>

### Sitemap Configuration

Configure which pages appear in the sitemap:

```php [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:

```php [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:

```yaml [site/blueprints/pages/landing.yml]
options:
  sitemap: false
```

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

```yaml [site/blueprints/pages/default.yml]
fields:
  sitemap:
    type: toggle
    label: Include in Sitemap
    default: true
```

```php [config.php]
return [
    'headless' => [
        'sitemap' => [
            'isIndexable' => function ($page) {
                return $page->sitemap()->toBool();
            }
        ]
    ]
];
```

<note>

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`.

</note>

---

Every page of this site as Markdown: <https://kirby.tools/sitemap.md>
