API Builder

Compose routes from middleware chains – built-ins for bearer auth, file/page resolution, plus your own validators.

Kirby Headless includes an Express-style API builder for creating custom routes with middleware support. This allows you to reuse logic like authentication, validation, and error handling across multiple routes.

API Builder Basics

The API builder is provided by the JohannSchopplich\Headless\Api\Api class. Use Api::createHandler() to chain middleware functions that execute sequentially before your final route handler.

How It Works

Middleware functions receive a $context array and the route $args. What a middleware returns decides what happens next:

Return valueEffect
nullThe next middleware runs with the context unchanged
An arrayIt is merged into the context every later middleware receives
Anything elseIt is the answer and ends the chain

The last row covers everything Kirby's router knows how to send: a Response, a Responder – what $kirby->response() returns – a File, a Page, or a plain string.

Creating Routes

Basic Route

Create a simple API route accessible at /api/hello:

config.php
use JohannSchopplich\Headless\Api\Api;

return [
    'routes' => [
        [
            'pattern' => 'api/hello',
            'method' => 'GET',
            'action' => Api::createHandler(
                function (array $context, array $args) {
                    return Api::createResponse(200, [
                        'message' => 'Hello World'
                    ]);
                }
            )
        ]
    ]
];

Route With Authentication

Protect routes using the built-in bearer token middleware:

config.php
use JohannSchopplich\Headless\Api\Api;
use JohannSchopplich\Headless\Api\Middlewares;

return [
    'routes' => [
        [
            'pattern' => 'api/protected',
            'method' => 'GET',
            'action' => Api::createHandler(
                Middlewares::hasBearerToken(),
                function (array $context, array $args) {
                    return Api::createResponse(200, [
                        'data' => 'Protected content'
                    ]);
                }
            )
        ]
    ]
];

Extending Kirby's API

Add routes to Kirby's /api namespace:

config.php
use JohannSchopplich\Headless\Api\Api;
use JohannSchopplich\Headless\Api\Middlewares;

return [
    'api' => [
        'routes' => [
            [
                'pattern' => 'posts',
                'method' => 'GET',
                'auth' => false, // Disable Kirby's default auth
                'action' => Api::createHandler(
                    Middlewares::hasBearerToken(),
                    function (array $context, array $args) {
                        $posts = kirby()->site()->find('blog')->children();

                        return Api::createResponse(200, [
                            'posts' => $posts->map(fn ($p) => [
                                'title' => $p->title()->value(),
                                'uri' => $p->uri()
                            ])->values()
                        ]);
                    }
                )
            ]
        ]
    ]
];

Built-in Middlewares

Kirby Headless provides several built-in middleware functions in the Middlewares class:

Order matters. The handler returns as soon as a middleware yields anything but null or an array, so hasBearerToken() has to come first in your chain – otherwise a resolver hands out its result before the token is ever checked.

hasBearerToken()

Validates the bearer token against the configured headless.token. It does not redirect by default; pass hasBearerToken(true) to redirect browser navigations to the Panel when headless.panel.redirect is enabled – that is, requests with no Authorization header whose Accept header asks for something other than JSON:

use JohannSchopplich\Headless\Api\Middlewares;

Api::createHandler(
    Middlewares::hasBearerToken(),
    function (array $context, array $args) {
        // Token is valid
    }
);

applyLanguageHeader()

Sets the current language and translation from the X-Language header, so t() in a template speaks the language its content is in. It only speaks for a path that names no language of its own: a URL reached through a language prefix keeps that language, and a path carrying another language's prefix is left to tryResolvePage(). A code that matches no language of the site is ignored, including default and current, which Kirby's own language() would otherwise resolve. Place it after hasBearerToken() and before the resolvers:

use JohannSchopplich\Headless\Api\Middlewares;

Api::createHandler(
    Middlewares::hasBearerToken(true),
    Middlewares::applyLanguageHeader(...),
    Middlewares::tryResolveFiles(...),
    Middlewares::tryResolvePage(...)
);

tryResolveFiles()

Attempts to resolve page and site files from the request path, mirroring Kirby's own resolver: a page wins whenever its ID matches the path without its extension, site files are only addressable at the root level, and every match is filtered through Kirby's content.fileRedirects option, which is disabled by default:

use JohannSchopplich\Headless\Api\Middlewares;

Api::createHandler(
    Middlewares::hasBearerToken(),
    Middlewares::tryResolveFiles(...),
    function (array $context, array $args) {
        // File resolution attempted
    }
);

hasBody()

Answers 400 with {"error": "Missing request body"} when the request carries no parsed body, and otherwise puts Kirby's Body object into $context['body'] for the middlewares that follow. Unlike the others it is a plain middleware, so pass it by reference rather than calling it:

use JohannSchopplich\Headless\Api\Middlewares;

Api::createHandler(
    Middlewares::hasBearerToken(),
    Middlewares::hasBody(...),
    function (array $context, array $args) {
        $data = $context['body']->data();
    }
);

tryResolvePage()

Attempts to resolve page requests, honoring the content representation Kirby would serve for the path's extension: the extensionless path and .json return page JSON, .html redirects to the canonical page URL, and any other extension needs a matching *.php representation. Drafts resolve for a logged-in Panel user or a valid preview token. The answer is Kirby's response object, so the template's own status code and headers are part of it. Returns null when the path carries another language's URL prefix – unless the current language resolves the full path to a page of its own – so Kirby's language cascade can continue:

use JohannSchopplich\Headless\Api\Middlewares;

Api::createHandler(
    Middlewares::tryResolvePage(...),
    function (array $context, array $args) {
        // Page resolution attempted
    }
);

Custom Middleware

Create custom middleware functions to handle validation, data transformation, or other logic:

config.php
use JohannSchopplich\Headless\Api\Api;

// Define custom middleware
$requireDateParam = function (array $context, array $args) {
    $date = kirby()->request()->get('date');

    if (empty($date)) {
        return Api::createResponse(400, [
            'error' => 'Missing date parameter'
        ]);
    }

    // Add date to context for use in later handlers
    $context['date'] = $date;
    return $context;
};

return [
    'routes' => [
        [
            'pattern' => 'api/events',
            'method' => 'GET',
            'action' => Api::createHandler(
                $requireDateParam,
                function (array $context, array $args) {
                    $date = $context['date'];

                    return Api::createResponse(200, [
                        'date' => $date,
                        'events' => [] // Your event data
                    ]);
                }
            )
        ]
    ]
];

Response Format

All responses use a consistent JSON format with status code and optional result data:

{
  "code": 200,
  "status": "OK",
  "result": {
    "message": "Success"
  }
}

Error responses follow the same pattern:

{
  "code": 401,
  "status": "Unauthorized"
}

status is the message Kirby lists for the code, with 204, 409 and 422 named by the plugin itself. Any other code – 429, say – gets the name of its class instead, so Api::createResponse() answers with any status code you pass it:

{
  "code": 429,
  "status": "Client Error"
}

Examples

POST Endpoint With Validation

config.php
use JohannSchopplich\Headless\Api\Api;
use JohannSchopplich\Headless\Api\Middlewares;

$requireTitle = function (array $context, array $args) {
    $data = $context['body']->data();

    if (empty($data['title'])) {
        return Api::createResponse(400, [
            'error' => 'Title is required'
        ]);
    }

    $context['data'] = $data;
    return $context;
};

return [
    'routes' => [
        [
            'pattern' => 'api/pages',
            'method' => 'POST',
            'action' => Api::createHandler(
                Middlewares::hasBearerToken(),
                Middlewares::hasBody(...),
                $requireTitle,
                function (array $context, array $args) {
                    $data = $context['data'];

                    // Create page logic here

                    return Api::createResponse(201, [
                        'message' => 'Page created'
                    ]);
                }
            )
        ]
    ]
];