Kirby CMS as Headless CMS via API
Kirby CMS has a built-in Content Representations mechanism and official headless mode via KirbyQL or REST. For production headless, the official kirby-headless-starter plugin or custom routes are recommended.
Built-in Content Representations
Kirby allows serving content as JSON via .json.php files in templates:
// site/templates/blog.json.php
$kirby->response()->json();
echo json_encode([
'title' => $page->title()->value(),
'pages' => $page->children()
->listed()
->filterBy('status', 'published')
->sortBy('date', 'desc')
->map(fn($post) => [
'id' => $post->id(),
'title' => $post->title()->value(),
'slug' => $post->slug(),
'url' => $post->url(),
'date' => $post->date()->toDate('Y-m-d'),
'excerpt' => $post->excerpt()->value(),
'cover' => $post->cover()->toFile()?->url(),
])
->values(),
]);
Request: GET /blog.json
KirbyQL — GraphQL-like API
composer require getkirby/kql
// Request to /api/query
const response = await fetch(`${KIRBY_URL}/api/query`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Basic ${btoa(`${KIRBY_EMAIL}:${KIRBY_PASSWORD}`)}`,
},
body: JSON.stringify({
query: {
pages: {
query: 'page("blog").children.listed.sortBy("date", "desc").paginate(12)',
select: {
id: true,
title: true,
slug: true,
url: true,
date: 'page.date.toDate("Y-m-d")',
excerpt: true,
cover: {
query: 'page.cover.toFile',
select: { url: true, width: true, height: true, alt: true },
},
categories: {
query: 'page.categories.toPages',
select: { title: true, slug: true, url: true },
},
},
pagination: { page: 1, limit: 12 },
},
},
}),
});
API authentication
// site/config/config.php
return [
'api' => [
'allowInsecure' => false,
'basicAuth' => true,
'cors' => true,
],
'api.cors' => [
'allowMethods' => 'GET, POST, OPTIONS',
'allowOrigin' => env('FRONTEND_URL', '*'),
'allowHeaders' => 'Authorization, Content-Type',
'maxAge' => '300',
],
];
Better to use a read-only API User:
// site/accounts/[email protected]
<?php return [
'email' => '[email protected]',
'role' => 'api',
'password' => password_hash('readonly-password', PASSWORD_DEFAULT),
];
Custom API routes
// site/config/config.php
'routes' => [
[
'pattern' => 'api/v1/blog',
'action' => function () {
return Response::json([
'posts' => page('blog')
->children()
->listed()
->sortBy('date', 'desc')
->toArray(fn($p) => [
'title' => $p->title()->value(),
'slug' => $p->slug(),
'url' => $p->url(),
'date' => $p->date()->toDate('Y-m-d'),
'excerpt' => $p->excerpt()->value(),
]),
]);
},
'method' => 'GET',
],
[
'pattern' => 'api/v1/blog/(:any)',
'action' => function (string $slug) {
$post = page('blog/' . $slug);
if (!$post) return Response::json(['error' => 'Not found'], 404);
return Response::json([
'title' => $post->title()->value(),
'content' => $post->text()->kirbytext()->value(), // HTML
'date' => $post->date()->toDate('Y-m-d'),
]);
},
'method' => 'GET',
],
],
Next.js integration
// lib/kirby.ts
const KQL_ENDPOINT = `${process.env.KIRBY_URL}/api/query`;
const AUTH = Buffer.from(`${process.env.KIRBY_API_USER}:${process.env.KIRBY_API_PASSWORD}`).toString('base64');
export async function kqlQuery(query: object) {
const res = await fetch(KQL_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Basic ${AUTH}`,
},
body: JSON.stringify({ query }),
next: { revalidate: 3600 },
});
return res.json();
}
Setting up Kirby headless with KQL and Next.js frontend — 2–4 days.







