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:

site/config/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:

site/config/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:

site/config/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: 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, as described under Panel Redirect:

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. A language prefix in the path wins over the header, and a code that matches no language of the site is ignored. 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 the way Kirby's own resolver does:

  • A page wins whenever its ID matches the path without its extension.
  • Site files are only addressable at the root level.
  • 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. Like the resolvers it is a plain middleware, so pass it by reference; only hasBearerToken() is called:

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:

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:

site/config/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

Api::createResponse() answers with a JSON envelope that carries the status code and, when you pass data, a result:

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

Error responses follow the same pattern:

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

status is Kirby's message for the code, or the name of its class – Client Error, Server Error – where Kirby lists none.

Examples

POST Endpoint With Validation

site/config/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'
                    ]);
                }
            )
        ]
    ]
];