---
title: Blog
description: 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](/modules/authors) module, not from here.

> **INFO: Release-independent**
>
> Blog rows hang off the **project**, not a release. `?release=` is neither
> needed nor honoured: publishing an article shows it immediately whatever
> release is default, cloning a release does not copy the back catalogue, and
> promoting one does not revert it. Custom content is the opposite. See
> [Releases](/concepts/releases).

## List articles

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

```http
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`).

## Categories

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

## The article shape

```json
{
  "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.

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

> **WARNING: There is no singular `author` or `category`**
>
> Two names for one thing is how a contract rots: the singular would have to mean
> `authors[0]`, and the day somebody set one without the other there would be no
> rule for which won. Use element 0.

## 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](/concepts/seo).

## Notes

- Everything here is **read-only** and works with any key kind. Articles are
  written in the dashboard.
- Only **published** articles are served. A draft is a 404 that says it is a
  draft.
- A future `published_at` does **not** publish an article by itself.
  Scheduling is not built.
- 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](/concepts/rich-text).