Built-in modules
Blog
Articles, categories and a resolved SEO chain, in a built-in module you configure nothing for.
A built-in module: articles and categories whose schema ZevContent defines, documents and maintains. Nothing is configured first. Every project has these endpoints, and they serve an empty list until somebody writes something.
Bylines come from the shared authors module, not from here.
List articles
/v1/blog/articles Published articles, newest first by published_at. Cursor-paginated. body is
omitted, so fetch one article for it.
| Parameter | |
|---|---|
limit | 1–100, default 20 |
cursor | meta.next_cursor from the previous page |
category | A category slug. Matches an article carrying it anywhere in categories. Unknown → error. |
author | An author slug. Matches anywhere in authors, so a co-authored post appears on both pages. Unknown → error. |
tag | An exact tag. No match → empty list. |
q | Case-insensitive substring of title or excerpt |
media_urls | permanent to refuse expiring image URLs |
q combines with category and author, which is how a category page or an
author page offers search within its own articles without a second
endpoint.
One article
/v1/blog/articles/{slug} The whole article, with body as { html, json, plain, toc }, every image
resolved, and the same toc beside the article so a sidebar table of contents
does not have to reach inside the body.
An unknown slug teaches with did_you_mean; a slug that exists but is a draft
says so (blog_article_not_published).
Search
/v1/blog/search?q=deploy Ranked full-text search over titles, excerpts, tags and bodies, best match
first. This is what a search box on the site should call. q on the article
list is a substring filter for narrowing a list; this reads inside the body,
stems words so “deploying” finds “deploy”, and puts a title match above a
body match.
q reads like a search box: every word is required, a quoted phrase stays
together, and a leading minus excludes a word. It takes the same category,
author and tag filters as the list, pages by offset, and is empty rather
than an error when nothing matches.
Each result is the list shape plus two fields:
{
"id": "art_6cRpCYFMCnAqFyCMpM4LF",
"slug": "how-we-deploy",
"title": "How we deploy",
"rank": 0.6079,
"snippet": "every <mark>deploy</mark> goes through the same three gates, and the third one is a person"
}
rank is relative within one response; compare it between results, not
between searches. snippet is the passage around the match, already
HTML-escaped, with the matched words wrapped in <mark>, so it can go
straight into a results list.
Categories
/v1/blog/categories /v1/blog/categories/{slug} Every category alphabetically, each with article_count, its featured_image
and its resolved seo. That is enough to build blog navigation, section cards
included, from one request.
A category page is two requests and never more: the page itself, and
GET /v1/blog/articles?category={slug} for its articles, which pages and takes
q.
Writing
Secret key only. Reads work with any key kind; a publishable key attempting
a write is refused with secret_key_required.
An import runs in dependency order: authors and categories first, then articles referencing them by id.
/v1/blog/articles {
"title": "A post from 2019",
"body": { "version": 2, "content": [ "…" ] },
"author_ids": ["aut_KPRsgVcpF5Ex3uLXjPLFH"],
"category_ids": ["cat_M23QDT865pGD9Xycb4m7p"],
"tags": ["migration"],
"published_at": "2019-04-01T09:00:00.000Z",
"publish": true
}
Every key is snake_case, matching what the same field is served under, so you can read an article, change one value and post it back without a translation layer.
title | Required. |
slug | Derived from the title when omitted, and made unique if taken. |
body | A rich text document. |
author_ids · category_ids | aut_… / cat_… ids, in order; the first is the primary one. Replaces the whole list. |
published_at | A past date backdates an import; a future date schedules. |
publish | false by default, which creates a draft. |
/v1/blog/articles/{slug} Same body. Omitted fields are left alone, explicit nulls clear. It addresses drafts and scheduled articles as well as live ones, because the commonest next step after an import is fixing what it just created.
/v1/blog/categories { "name": "Engineering", "description": "How we build things." }
Returns the cat_… id for category_ids. Authors are their own module:
POST /v1/authors.
The article shape
{
"id": "art_…",
"slug": "how-we-deploy",
"title": "How we deploy",
"excerpt": "A short tour of the pipeline.",
"body": { "html": "…", "json": {}, "plain": "…", "toc": [] },
"toc": [{ "level": 2, "text": "The pipeline", "id": "the-pipeline" }],
"featured_image": { "url": "…", "alt": "…", "width": 1600, "height": 900, "content_type": "image/jpeg" },
"authors": [{ "id": "aut_…", "name": "Ada", "slug": "ada", "bio": "…", "avatar": null, "social": {} }],
"categories": [{ "id": "cat_…", "name": "Engineering", "slug": "engineering", "description": null }],
"tags": ["deploys", "ci"],
"published_at": "2026-08-14T09:00:00.000Z",
"updated_at": "2026-08-20T11:12:00.000Z",
"reading_time_minutes": 6,
"word_count": 1180,
"seo": { "title": "…", "description": "…", "og_image": {}, "canonical_url": null, "noindex": false }
}
body and toc appear on one article only, never in a listing. Twenty full
rich text documents, each rendered three ways, is a response measured in
megabytes for a page that shows twenty cards.
Dates
Every article carries two, both ISO 8601 with a Z:
| Field | |
|---|---|
published_at | When the article says it was published. Set by the writer, not by the server clock. |
updated_at | When the row last changed, including edits after publishing. Always server-set. |
published_at defaults to the moment an article is first published, and a
writer can change it to any date. That matters twice:
- A past date is what an imported back catalogue needs. A blog moved here from somewhere else keeps its real dates instead of arriving all at once with today’s, which is the difference between an archive and a pile.
- A future date schedules the article. It is published, and it appears by itself at that moment.
Sorting is by published_at, newest first, so backdating moves an article
down the feed. That is the intent: an imported post from 2019 belongs in 2019.
Arrays, and the first element
authors and categories are ordered arrays, and the first element of
each is the primary one:
authors[0]is the lead byline: the name to print when there is room for one.categories[0]is the primary section: the breadcrumb, the eyebrow label, the section URL.
The order is the one a writer chose. Render it as given; never re-sort.
Both may be empty, since an unattributed post and an uncategorised one are both ordinary, but neither is ever null, so map over them without a check.
References and pages
Nested inside an article, an author and a category are references: the
byline and the breadcrumb. They carry no article_count and no seo, because
a card printing twenty bylines will never render twenty og images.
On their own endpoints they are pages and carry those as well. The category
page is /v1/blog/categories/{slug}; the author page is /v1/authors/{slug},
which is not under /v1/blog.
Categories and tags are different things
A category is a taxonomy term with a page of its own: a slug, a
description, an image, an SEO object, and a URL. A tag is a free-form label
with no page. Many per article, matched exactly by ?tag=.
If you find yourself wanting a page for a tag, it should have been a category.
SEO
Every article and category carries a complete, resolved seo object. Do not
reimplement the fallback chain. See SEO.
Notes
- Reads work with any key kind. Writes need a secret key. See Writing.
- Only published articles are served. A draft is a 404 that says it is a draft.
- A future
published_atschedules the article: it is published, hidden from every reader until that moment, and appears by itself then. No job runs and nothing has to be remembered. - The body is rich text at schema version 2. An article written before schema 2
keeps
"version": 1and renders exactly as it always did. See Rich text.
Updated at, Sunday, September 20, 2026