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:
| Parameter | Type | Default | Description |
|---|---|---|---|
tag | string | – | Filter by tag |
author | string | – | Filter by author |
sort | string | url | Sort field: url, title, or date |
order | string | asc | Sort direction: asc or desc |
limit | integer | 50 | Results per page (max 500) |
offset | integer | 0 | Pagination 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.
Search
GET /api/v1/search?q=term
| Parameter | Type | Default | Description |
|---|---|---|---|
q | string | required | Search query |
limit | integer | 20 | Max 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:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | The configured requests per minute |
X-RateLimit-Remaining | Requests left in the current window |
X-RateLimit-Reset | Seconds 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: trueto count by the firstX-Forwarded-Forentry 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_limitalso takes effect on restart, not on a configuration reload (SIGHUPorPOST /_admin/reload), because it decides what the server mounts – the same asenabledandprefix.
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:
| URL | accent serve | Static host |
|---|---|---|
/api/v1/pages/blog/welcome.json | yes | yes |
/api/v1/pages/blog/welcome | yes | no |
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/searchneeds a running server. In a static deployment, use the client-side search index at/_search/index.jsoninstead; it has a different shape. - No pagination. The served
/api/v1/pagespaginates withlimitandoffset; the builtpages.jsonis the whole list in one file, withmeta.limitequal tometa.total.