Grav REST API — JSON Responses, Endpoints and External Integrations
In this tutorial, you'll learn the Grav REST API — creating custom JSON endpoints, returning page collections and content as JSON, building API controllers with authentication, and integrating Grav with external applications.
What You'll Learn
- How Grav handles JSON requests
- Returning page content as JSON
- Collection endpoints: listing pages as JSON API
- Building custom API controllers in plugins
- API authentication and security
- Rate limiting and caching for APIs
- External integrations with Grav data
Why It Matters
In WordPress, the REST API is built into the core since version 4.7. In Grav, there is no built-in REST API, but creating one is straightforward using Grav's routing and response system. A custom API lets you expose Grav content to mobile apps, single-page applications, external services, or automation tools. You decide what data to expose, in what format, and with what authentication.
Real-World Use
A mobile app for a documentation site needs to fetch articles, search content, and sync updates. The Grav site exposes a custom API at /api/articles that returns page data as JSON. The mobile app calls this endpoint with a language parameter and receives translated content. Updates to the documentation are available to the app immediately — no app store update needed.
Learning Path
flowchart LR
A["Media Handling"] --> B["Grav API
← You are here"]:::current
B --> C["Web Services"]
C --> D["E-commerce with Grav"]
D --> E["Caching Deep Dive"]
E --> F["Performance Optimization"]
F --> G["Security"]
classDef current fill:#38bdf8,color:#0f172a,stroke-width:2px
JSON from Page Collections
Grav can return page collections as JSON by appending .json to the URL:
/blog.json
/blog/tag/grav.json
This returns the collection defined in the page's frontmatter as JSON:
{
"title": "Blog",
"content": "<p>Latest posts</p>",
"collection": [
{
"title": "First Post",
"route": "/blog/first-post",
"url": "http://localhost:8000/blog/first-post",
"content": "<p>Post content...</p>",
"date": "2026-06-27 10:00:00",
"taxonomy": {"tag": ["grav"]}
}
]
}
Creating API Endpoints in a Plugin
Step 1: Route Registration
user/plugins/api/api.php:
<?php
namespace Grav\Plugin;
use Grav\Common\Plugin;
use Grav\Common\Page\Page;
use Grav\Plugin\Api\ApiController;
class ApiPlugin extends Plugin
{
public static function getSubscribedEvents()
{
return [
'onPluginsInitialized' => ['onPluginsInitialized', 0],
];
}
public function onPluginsInitialized()
{
$this->enable([
'onPageInitialized' => ['onPageInitialized', 0],
]);
}
public function onPageInitialized()
{
$route = $this->grav['uri']->route();
if (strpos($route, '/api') === 0) {
require_once __DIR__ . '/classes/ApiController.php';
$controller = new ApiController($this->grav);
$controller->handle($route);
}
}
}
Step 2: API Controller
user/plugins/api/classes/ApiController.php:
<?php
namespace Grav\Plugin\Api;
use Grav\Common\Grav;
use Symfony\Component\HttpFoundation\JsonResponse;
class ApiController
{
protected $grav;
public function __construct($grav)
{
$this->grav = $grav;
}
public function handle($route)
{
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
switch (true) {
case $route === '/api/pages':
$this->listPages();
break;
case preg_match('#^/api/pages/(.+)$#', $route, $matches):
$this->getPage($matches[1]);
break;
case $route === '/api/search':
$this->search();
break;
default:
$this->json(['error' => 'Not found'], 404);
}
}
protected function listPages()
{
$pages = $this->grav['pages']->all();
$data = [];
foreach ($pages as $page) {
if ($page->published()) {
$data[] = $this->pageToArray($page);
}
}
$this->json([
'count' => count($data),
'pages' => $data,
]);
}
protected function getPage($route)
{
$page = $this->grav['pages']->find('/' . $route);
if (!$page || !$page->published()) {
$this->json(['error' => 'Page not found'], 404);
return;
}
$this->json([
'page' => $this->pageToArray($page, true),
]);
}
protected function search()
{
$query = $_GET['q'] ?? '';
$results = [];
if (strlen($query) >= 2) {
$pages = $this->grav['pages']->all();
foreach ($pages as $page) {
if ($page->published() &&
stripos($page->title(), $query) !== false) {
$results[] = $this->pageToArray($page);
}
}
}
$this->json([
'query' => $query,
'count' => count($results),
'results' => $results,
]);
}
protected function pageToArray($page, $includeContent = false)
{
$data = [
'id' => $page->slug(),
'title' => $page->title(),
'route' => $page->route(),
'url' => $page->url(true),
'date' => $page->date(),
'modified' => $page->modified(),
'taxonomy' => $page->taxonomy(),
'template' => $page->template(),
];
if ($includeContent) {
$data['content'] = $page->content();
$data['header'] = $page->header();
$data['metadata'] = $page->metadata();
}
return $data;
}
protected function json($data, $status = 200)
{
http_response_code($status);
echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
exit;
}
}
API Authentication
Add token-based authentication:
protected function authenticate()
{
$token = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
$token = str_replace('Bearer ', '', $token);
$validTokens = $this->grav['config']->get('plugins.api.tokens', []);
if (!in_array($token, $validTokens)) {
$this->json(['error' => 'Unauthorized'], 401);
}
}
Usage:
public function handle($route)
{
$this->authenticate();
// ... route handling
}
API Caching
Add caching to API responses:
protected function cachedJson($key, $data, $ttl = 300)
{
$cache = $this->grav['cache'];
$cacheKey = 'api_' . md5($key);
if ($cached = $cache->fetch($cacheKey)) {
return $cached;
}
$response = json_encode($data);
$cache->save($cacheKey, $response, $ttl);
return $response;
}
Rate Limiting
Simple IP-based rate limiting:
protected function checkRateLimit($maxRequests = 60, $window = 60)
{
$ip = $_SERVER['REMOTE_ADDR'];
$cache = $this->grav['cache'];
$key = 'ratelimit_' . md5($ip);
$data = $cache->fetch($key) ?: ['count' => 0, 'reset' => time() + $window];
if ($data['count'] >= $maxRequests) {
$this->json(['error' => 'Rate limit exceeded'], 429);
}
$data['count']++;
$cache->save($key, $data, $window);
}
Query Parameter Support
protected function buildQuery($params)
{
$query = [
'taxonomy' => [],
'order' => ['by' => 'date', 'dir' => 'desc'],
'limit' => 20,
];
if (isset($params['tag'])) {
$query['taxonomy']['tag'] = explode(',', $params['tag']);
}
if (isset($params['category'])) {
$query['taxonomy']['category'] = $params['category'];
}
if (isset($params['limit'])) {
$query['limit'] = (int)$params['limit'];
}
if (isset($params['sort'])) {
$query['order']['by'] = $params['sort'];
}
if (isset($params['order'])) {
$query['order']['dir'] = $params['order'];
}
return $query;
}
Usage:
GET /api/pages?tag=grav,tutorial&limit=10&sort=title&order=asc
Learning Path
flowchart LR
A["Media Handling"] --> B["Grav API
← You are here"]:::current
B --> C["Web Services"]
C --> D["E-commerce with Grav"]
D --> E["Caching Deep Dive"]
classDef current fill:#38bdf8,color:#0f172a,stroke-width:2px
Common Mistakes
Not setting CORS headers: If your API is consumed by a browser-based app (JavaScript fetch), you need
Access-Control-Allow-Originheaders. Without them, the browser blocks the request.Exposing unpublished content: Always check
$page->published()before including a page in API output. Without it, draft pages are visible through the API.No authentication for write operations: If your API supports POST, PUT, or DELETE operations (creating/updating pages), require authentication. Without it, anyone can modify your site.
Not caching API responses: Without caching, every API request triggers a full Grav initialization and page listing. For production APIs, cache responses aggressively.
Hardcoding API routes: Use Grav routing instead of hardcoded
switchstatements for route matching. This makes your API easier to extend and maintain.
Practice Questions
How do you return a page collection as JSON in Grav? Answer: Simply append
.jsonto the collection page URL. Grav automatically serializes the collection as JSON. For example,/blog.jsonreturns the blog collection.How do you implement authentication for a custom API endpoint? Answer: Extract the
Authorizationheader (Bearer token), compare against a list of valid tokens from configuration, and return a 401 response if the token is invalid.What is the purpose of CORS headers in an API? Answer: CORS (Cross-Origin Resource Sharing) headers tell browsers that it is safe to make requests from a different domain. Without
Access-Control-Allow-Origin: *, browser-based clients cannot consume the API.How do you rate-limit API requests? Answer: Track request counts per IP address in the cache with a time window. If the count exceeds the limit, return a 429 (Too Many Requests) response.
Challenge: Build a complete REST API plugin for a documentation site. Endpoints should include: GET
/api/pages(list all published pages with pagination), GET/api/pages/{route}(get a single page with full content and metadata), GET/api/search?q=term(search pages by title and content), GET/api/taxonomy/{type}(list all taxonomy terms of a given type), GET/api/pages/{route}/children(list child pages), and POST/api/contact(submit a contact form, requires authentication). Implement authentication via Bearer tokens, rate limiting (100 requests/minute/IP), caching (5-minute TTL), CORS headers, proper error responses with HTTP status codes, and comprehensive query parameter support (filter, sort, limit, page).
FAQ
Mini Project
Goal: Build a complete REST API for a Grav site with 6 endpoints.
- Create an API plugin with route handling at
/api/* - Implement GET
/api/pageswith pagination, filtering, sorting - Implement GET
/api/pages/{route}with full page content - Implement GET
/api/search?q=termwith title and content search - Implement GET
/api/taxonomy/{type}listing all taxonomy terms - Add Bearer token authentication
- Add rate limiting (60 requests/minute)
- Add response caching with 5-minute TTL
- Add CORS headers for cross-origin access
- Test all endpoints with curl and create API documentation
What's Next
Now you have a functioning REST API. Next, learn Web Services:
Continue to Lesson 33: Web Services — Webhooks, OAuth, SSO, and LDAP authentication.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro