Skip to content

Grav REST API — JSON Responses, Endpoints and External Integrations

DodaTech Updated 2026-06-27 7 min read

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

  1. Not setting CORS headers: If your API is consumed by a browser-based app (JavaScript fetch), you need Access-Control-Allow-Origin headers. Without them, the browser blocks the request.

  2. Exposing unpublished content: Always check $page->published() before including a page in API output. Without it, draft pages are visible through the API.

  3. 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.

  4. Not caching API responses: Without caching, every API request triggers a full Grav initialization and page listing. For production APIs, cache responses aggressively.

  5. Hardcoding API routes: Use Grav routing instead of hardcoded switch statements for route matching. This makes your API easier to extend and maintain.

Practice Questions

  1. How do you return a page collection as JSON in Grav? Answer: Simply append .json to the collection page URL. Grav automatically serializes the collection as JSON. For example, /blog.json returns the blog collection.

  2. How do you implement authentication for a custom API endpoint? Answer: Extract the Authorization header (Bearer token), compare against a list of valid tokens from configuration, and return a 401 response if the token is invalid.

  3. 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.

  4. 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.

  5. 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

Can I expose the Grav Admin API to external applications?

The Admin plugin has its own internal API for the admin panel. For external access, build a custom API plugin that exposes only the data and actions you intend to make public.

How do I handle POST data in API endpoints?

Read the request body with file_get_contents('php://input') and decode JSON: json_decode(file_get_contents('php://input'), true) for POST/PUT requests.

Can I use Grav as a headless CMS?

Yes. Create an API plugin that returns content as JSON. Build a frontend with React, Vue, or any JavaScript framework that consumes your Grav API.

How do I add pagination to API responses?

Include page, per_page, and total fields in the response. Use $pages->all() with array slicing based on the page parameter.

What content types should my API support?

At minimum, JSON. For file downloads, also support the binary content type. For webhook callbacks, form-encoded data may be needed.

Mini Project

Goal: Build a complete REST API for a Grav site with 6 endpoints.

  1. Create an API plugin with route handling at /api/*
  2. Implement GET /api/pages with pagination, filtering, sorting
  3. Implement GET /api/pages/{route} with full page content
  4. Implement GET /api/search?q=term with title and content search
  5. Implement GET /api/taxonomy/{type} listing all taxonomy terms
  6. Add Bearer token authentication
  7. Add rate limiting (60 requests/minute)
  8. Add response caching with 5-minute TTL
  9. Add CORS headers for cross-origin access
  10. 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 ServicesWebhooks, OAuth, SSO, and LDAP authentication.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro