---
title: "Version Migration"
description: "Upgrade from v3 to v8 with before/after examples for global route authentication, CORS config changes, and resolved blocks structure updates."
canonical_url: "https://kirby.tools/docs/headless/migration"
---

# Version Migration

> Upgrade from v3 to v8 with before/after examples for global route authentication, CORS config changes, and resolved blocks structure updates.

## Migrating From v7 to v8

**Breaking Change:** Kirby Headless v8 validates the bearer token before the global catch-all route resolves anything, and brings routing, rendering and caching in line with Kirby's own resolver.

Most of the sections below describe the catch-all and only apply with `headless.globalRoutes` enabled. Five apply to every setup: [Empty Tokens Are Rejected](#empty-tokens-are-rejected), [Bearer Auth Without a Token Falls Back](#bearer-auth-without-a-token-falls-back), [Endpoint Caching Follows the Same Rules](#endpoint-caching-follows-the-same-rules), [Unknown Status Codes No Longer Throw](#unknown-status-codes-no-longer-throw) and [Middleware Return Values](#middleware-return-values), which reaches every chain you build with the API builder.

### Files Behind the Token

In v7, the catch-all route resolved files before validating the bearer token, so clean file URLs such as `/about/hero.jpg` responded without an `Authorization` header. In v8 the token is validated first.

Media URLs are unaffected. `/media/pages/…` is served by Kirby's own routes and never required a token, and since `$file->url()` returns the media URL by default, images in your frontend keep working unchanged.

### Clean File URLs Are Opt-In

Clean file URLs now pass through Kirby's `content.fileRedirects` option, which is disabled by default. If you link to paths such as `/about/hero.jpg`, enable it:

```php [config.php]
return [
    'content' => [
        'fileRedirects' => true
    ]
];
```

The option also accepts a closure to decide per file.

### Site Files Only at the Root Level

Site files now resolve only for paths without a slash, matching Kirby. In v7, any made-up path ending in the filename – for example `/some/deep/path/logo.png` – returned the site file `logo.png` as well.

### Pages Win Over Files

Kirby strips the extension before it looks up a page, so a page claims the path whenever its ID matches the path without that extension – whatever the extension is. With a page `blog/post`, a file `post.pdf` next to it is no longer served at `/blog/post.pdf`: the path belongs to the page, which then answers according to the rules below. In v7 the file won.

### Extensions Follow Kirby's Content Representations

The catch-all now answers the way Kirby's own resolver does:

- The extensionless path returns page JSON, rendered from the page's template.
- `.json` renders a `*.json.php` [content representation](https://getkirby.com/docs/guide/templates/content-representations) if you have one, and otherwise falls back to the page's template – so page JSON keeps working without any extra file.
- Any other extension renders its matching representation (`.xml` → `*.xml.php`) under the MIME type that extension implies.
- `.html` redirects to the canonical page URL with a `301`.
- An extension with no representation responds with the error page.

In v7 every extension returned page JSON with a `200`, and content representations were ignored entirely – so `/about.xml` served JSON under a URL that promised XML, even on a site that had an `about.xml.php`.

### Non-Default Languages Resolve Again

On multi-language sites the catch-all is scoped into every language's router, and the default language's pattern matches any path. In v7 the route answered a prefixed URL such as `/de/ueber-uns` from the default language and ended Kirby's language cascade before the German route was ever tried, so every non-default language responded with the error page. Only sites that gave *every* language a URL prefix were unaffected.

The route now steps aside for a path carrying another language's prefix, unless the current language resolves the full path to a page of its own. No configuration change is needed.

### Templates Shape the Response

The catch-all used to build its own response and discard whatever the template had configured, so a status code or a header set in a JSON template never left the server. The template now receives Kirby's response object and its settings win; Kirby Headless only fills in what is left. See [Shaping the Response](/docs/headless/usage/json-templates#shaping-the-response).

If one of your templates already sets a status code for its own reasons, that code now reaches the client. Nothing changes for templates that only echo JSON.

### Drafts Render With a Preview Token

A draft used to answer with the error page under every circumstance. It now renders for a logged-in Panel user who may access the page, or for a request carrying a valid preview token – the same rule Kirby applies to its own rendering. Public requests still get the error page. See [Previewing Drafts](/docs/headless/usage/json-templates#previewing-drafts).

### The Page Cache Mirrors Kirby's

Cached responses now store the response configuration next to the body, and the cache key gained the language, the content type and the rendered version. Two consequences:

- `$kirby->response()->cache(false)` and `expires()` are honored, where before every response was cached until the content changed.
- A response that depended on the visitor's credentials is no longer replayed for anyone else.

<note>

Entries written by earlier versions have a different shape and are ignored rather than reused, so the first request per page repopulates the cache.

</note>

### Endpoint Caching Follows the Same Rules

`/api/__sitemap__` and `/api/__template__` cached every response, including those for requests carrying query or body data that their cache key could not tell apart. Both now render fresh for such requests, and both accept the `X-Cacheable: false` header that the KQL endpoint has always honored.

### Unknown Status Codes No Longer Throw

`Api::createResponse()` threw an exception for any status code outside a list of eleven. It now takes the message from Kirby's own table and falls back to the class of the code – `Client Error`, `Server Error` – for anything unlisted, so a custom route can answer with `429` or `507`.

### Middleware Return Values

A middleware returning something other than a `Response`, a `File` or an array used to be ignored silently. Anything but `null` and arrays now ends the chain and becomes the answer, which lets a middleware return a `Page` or a plain string the way a Kirby route can. If you relied on a return value being dropped, return `null` instead.

### The Catch-All Answers Every Request Method

The route is now registered for every HTTP method. In v7 it matched `GET` only, so `POST`, `HEAD`, `PUT`, `PATCH`, `DELETE` and `OPTIONS` fell through to Kirby's own catch-all and served pages and files without ever reaching the token check.

### Empty Tokens Are Rejected

Leaving `headless.token` unset still disables authentication. A token that is set but blank – empty or whitespace only, the common result of an unresolved environment variable – now rejects every request with `401` instead of serving the site publicly.

### Bearer Auth Without a Token Falls Back

Setting `kql.auth` to `'bearer'` without a `headless.token` used to leave `/api/kql` open to everyone. It now falls back to Kirby's native API authentication. Use `kql.auth => false` if you want the endpoint public on purpose.

### The Panel Redirect Only Catches Browsers

With `headless.panel.redirect` enabled, a request is only redirected to the Panel when it sends an `Accept` header that asks for something other than JSON – which is what a browser navigation does. A client that asks for JSON, or sends no `Accept` header at all, gets a JSON answer instead. In v7 every request without an `Authorization` header was redirected, which stranded any HTTP client that does not set `Accept` by default.

## Migrating From v6 to v7

**Breaking Change:** Kirby Headless v7 requires Kirby 5 and corrects the status codes the endpoints answer with. A frontend that checks status codes needs updating; nothing in `config.php` does. Stay on the v6 line until the site itself is upgraded.

### Endpoints Answer 200 Instead of 201

`/api/__sitemap__` and `/api/__template__` responded with `201 Created`, which read as a write to every HTTP client that looks at the status. Both are read-only and now answer `200`. Client code branching on `201` – or a `response.status === 201` check in a test – has to accept `200`.

### Missing Pages Answer 404

A request for a page that does not exist served the error page under a `200`. It now responds with `404`, and the error page is the body. A frontend that only checked the status now sees the failure it was missing; one that checked the body for an error marker keeps working.

### The Sitemap Payload Changed

Two entries answer differently, so a frontend that reads them needs a second look:

- A page with no resolvable modification date emitted `"modified": null`. The key is now left out entirely – read it as optional rather than nullable.
- Only a trailing `.utf-8` was stripped from a language's `LC_ALL` locale before an hreflang code was derived. Every charset and modifier is now removed, so `de_DE@euro` yields `de-de` where it used to yield `de-de-euro`.

### Render Hooks Fire for API Requests

`page.render:before` and `page.render:after` now run when the catch-all renders a page, matching Kirby's own `Page::render()`. A hook you wrote for the Panel or for HTML rendering now also runs for every headless request – check that it does not assume a browser.

### Resolved Fields Share the Bucket

With a [`resolvedKey`](/docs/headless/usage/field-methods#resolved-key) configured and more than one field of a block resolved, only the last field survived. All resolved fields of a block now appear side by side, so a frontend reading a single key finds its siblings next to it.

## Migrating From v5 to v6

**Breaking Change:** Kirby Headless v6 removes custom CORS handling in favor of Kirby's native CORS support, which needs Kirby 5.2.0 or higher.

### CORS Configuration Changes

If you were using `headless.cors` configuration in v5, migrate to Kirby's native `cors` option:

<tabs>
<tabs-item label="Before (v5)">

```php [config.php]
return [
    'headless' => [
        'cors' => [
            'allowOrigin' => '*',
            'allowMethods' => 'GET, POST, OPTIONS',
            'allowHeaders' => 'Accept, Content-Type, Authorization, X-Language, X-Cacheable',
            'maxAge' => '86400'
        ]
    ]
];
```

</tabs-item>

<tabs-item label="After (v6)">

```php [config.php]
return [
    'cors' => [
        'allowOrigin' => '*',
        'allowMethods' => ['GET', 'POST'],
        'allowHeaders' => ['Accept', 'Content-Type', 'Authorization', 'X-Language', 'X-Cacheable'],
        'maxAge' => 86400
    ]
];
```

</tabs-item>
</tabs>

<tip>

Note that `allowMethods` and `allowHeaders` now accept arrays instead of comma-separated strings, and `maxAge` accepts an integer instead of a string. The `OPTIONS` method is handled automatically by Kirby.

</tip>

<callout color="info" icon="i-ri-arrow-right-line" to="/docs/headless/configuration/cors">

See **CORS Configuration** for the full setup and more examples.

</callout>

---

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