JSON REST API

Accent CMS can serve your content as JSON through a lightweight REST API at /api/v1/.... This turns any Accent CMS site into a headless CMS that decoupled frontends (React, Vue, mobile apps) can query directly.

The API is disabled by default. Enable it in config.yaml:

api:
  enabled: true

accent serve answers the API over HTTP and accent build writes the same API to static files. Both use the same paths and return the same payloads, so you can develop a frontend against the dev server and deploy it against a CDN without changing a URL. See Build mode for how to write those URLs so they work in both places.

Endpoints

All endpoints return JSON with a consistent envelope format.

List Pages

GET /api/v1/pages

Query parameters:

ParameterTypeDefaultDescription
tagstring–Filter by tag
authorstring–Filter by author
sortstringurlSort field: url, title, or date
orderstringascSort direction: asc or desc
limitinteger50Results per page (max 500)
offsetinteger0Pagination offset

Example response:

{
  "data": [
    {
      "url": "/blog/welcome",
      "title": "Welcome",
      "date": "2026-01-15",
      "tags": ["blog"],
      "status": "visible",
      "word_count": 350
    }
  ],
  "meta": {
    "total": 42,
    "limit": 20,
    "offset": 0
  }
}

Get Single Page

GET /api/v1/pages/{path}

Returns full page detail including rendered HTML content (when include_content is enabled).

{
  "data": {
    "url": "/blog/welcome",
    "title": "Welcome",
    "template": "blog.html.jinja",
    "date": "2026-01-15",
    "author": "Admin",
    "lead": "A short summary...",
    "tags": ["blog"],
    "status": "visible",
    "word_count": 350,
    "reading_time": 2,
    "language": "default",
    "content": "<p>Rendered HTML...</p>",
    "custom": {
      "hero": "/media/hero.png"
    }
  }
}

Fields you have not set are omitted rather than returned as null. custom holds every frontmatter field beyond the built-in set, and is omitted when a page has none.

GET /api/v1/search?q=term
ParameterTypeDefaultDescription
qstringrequiredSearch query
limitinteger20Max results

Results are scored by relevance across title, URL, tags, lead, and content.

List Tags

GET /api/v1/tags

Returns all tags with page counts.

Get Tag

GET /api/v1/tags/{tag}

Returns a single tag with its associated pages.

Content Tree

GET /api/v1/tree

Returns the hierarchical content structure as a nested tree.

Schema

GET /api/v1/schema

Returns metadata about the content: available templates, tags, languages, and page count.

Configuration

All API settings with their defaults:

api:
  enabled: false             # Enable the REST API
  prefix: "/api/v1"          # Route prefix and static output directory
  cors: true                 # Add CORS headers
  allowed_origins: ["*"]     # CORS allowed origins
  rate_limit: 0              # Requests per minute per client (0 = unlimited)
  trust_forwarded_for: false # Read the client IP from X-Forwarded-For
  include_content: true      # Include rendered HTML in page detail

Setting include_content: false omits the content field from page detail responses, reducing payload size for listing-oriented consumers.

prefix moves the API on both surfaces at once: it is the route prefix accent serve mounts under and the directory accent build writes into. Keep it outside your content tree – the generated API shares that directory with any page whose URL falls under it, and wins a name clash. The build warns you when that happens.

CORS

When cors: true, the API sends CORS headers so browsers can call it from another origin. allowed_origins: ["*"] – the default – allows any origin. List your frontend’s origins to restrict it:

api:
  cors: true
  allowed_origins: ["https://app.example.com"]

The http_headers.cors block supersedes these two fields when it is enabled, and brings its own origin list. Use one or the other, not both.

Rate limiting

rate_limit caps how many API requests one client may make per minute. The default, 0, is unlimited. The limit applies to the API routes only – your HTML pages, assets and health probes are never throttled.

api:
  rate_limit: 120

While a limit is set, every API response carries the client’s standing:

HeaderMeaning
X-RateLimit-LimitThe configured requests per minute
X-RateLimit-RemainingRequests left in the current window
X-RateLimit-ResetSeconds until the window resets

A client over the limit gets 429 Too Many Requests with a Retry-After header and the usual JSON error body.

Two things to know before you rely on it:

  • Behind a proxy or CDN, the connection comes from the proxy, so every visitor shares one bucket. Set trust_forwarded_for: true to count by the first X-Forwarded-For entry instead. Only do this when a proxy you control is in front – on a directly reachable origin, a client can pick its own bucket by sending the header.
  • The counters live in the server process. They reset when it restarts, and several servers behind a load balancer each enforce the limit separately. Changing rate_limit also takes effect on restart, not on a configuration reload (SIGHUP or POST /_admin/reload), because it decides what the server mounts – the same as enabled and prefix.

Build mode

When the API is enabled, accent build writes the same API as static files under the configured prefix:

api/v1/pages.json
api/v1/pages/index.json          # the site root
api/v1/pages/blog/welcome.json
api/v1/tags.json
api/v1/tags/blog.json
api/v1/tree.json
api/v1/schema.json

Any static host serves these – Netlify, Vercel, Cloudflare Pages, S3 – with no rewrite rules and no running server.

Writing URLs that work in both places

Every endpoint answers with or without a .json suffix while accent serve is running, and the build writes the .json form. So use the suffix and one URL works everywhere:

URLaccent serveStatic host
/api/v1/pages/blog/welcome.jsonyesyes
/api/v1/pages/blog/welcomeyesno

The bare form is fine when you are only ever talking to a running server. If you might deploy the same frontend against a static build, use .json: a static host asked for a directory serves index.html, and there is nothing it can do with an extensionless path.

What the static API does not have

  • No search. /api/v1/search needs a running server. In a static deployment, use the client-side search index at /_search/index.json instead; it has a different shape.
  • No pagination. The served /api/v1/pages paginates with limit and offset; the built pages.json is the whole list in one file, with meta.limit equal to meta.total.