Docs
Dashboard

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

GET /v1/blog/articles

Published articles, newest first by published_at. Cursor-paginated. body is omitted, so fetch one article for it.

Parameter
limit1–100, default 20
cursormeta.next_cursor from the previous page
categoryA category slug. Matches an article carrying it anywhere in categories. Unknown → error.
authorAn author slug. Matches anywhere in authors, so a co-authored post appears on both pages. Unknown → error.
tagAn exact tag. No match → empty list.
qCase-insensitive substring of title or excerpt
media_urlspermanent 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

GET /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).

GET /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

GET /v1/blog/categories
GET /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.

POST /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.

titleRequired.
slugDerived from the title when omitted, and made unique if taken.
bodyA rich text document.
author_ids · category_idsaut_… / cat_… ids, in order; the first is the primary one. Replaces the whole list.
published_atA past date backdates an import; a future date schedules.
publishfalse by default, which creates a draft.
PATCH /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.

POST /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_atWhen the article says it was published. Set by the writer, not by the server clock.
updated_atWhen 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_at schedules 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": 1 and renders exactly as it always did. See Rich text.

Updated at, Sunday, September 20, 2026