Blog API
The public blog. Mounted at /api/blog. Shipped 2026-09-11 (SHAN-478 Phase 1). The human-readable surface it powers is /blog: a masonry index with a tag rail, and /blog/<slug> per post (SHAN-478 Phase 2).
This is the journal's mechanics without the journal's door. The journal is invite-only (SHAN-472): every route under /api/journal runs a membership check and 403s strangers. The blog runs none of it. Every GET below answers an anonymous request with no Authorization header at all.
Model#
A post is a slug plus an append-only chain of versions, exactly like a journal entry and its journal_versions. Two differences from the journal are worth reading before you write a client:
- Keyed by slug, not date. The journal is a diary: one entry per calendar day,
dateunique. A blog publishes many posts a day, so the natural key is a slug generated from the title. Slugs are stable forever; renaming a post does not move its URL. - Titles are versioned. A journal entry is titled by its date. A blog post carries a real title, and a rename is an edit like any other, recorded in the revision history and restored by a revert.
status is one of published, draft, trashed. Only published posts are world-readable. A draft is visible to its author and to nobody else, on both the list and the detail route. A trashed post is visible to nobody, including its author. Publishing a draft re-dates publishedAt to the moment it went public, so a post you started three weeks ago does not appear already buried; re-saving an already-published post leaves publishedAt alone.
Reads (public, no auth)#
| Method | Path | Notes |
|---|---|---|
| GET | /posts | {posts, nextCursor}, newest publishedAt first, each with contentExcerpt, wordCount and an author object. limit 1..100 (default 20), tag filters on the tags array, q searches title and body case-insensitively |
| GET | /posts/:slug | {post, author, title, content, currentVersionNum, prev, next} |
| GET | /posts/:slug/versions | {versions, nextCursor} newest first. Bodies are NOT included (versions are never pruned, so listing them would grow without bound); read one at a time below |
| GET | /posts/:slug/versions/:num | {version} including the full body |
Pagination on /posts is a keyset cursor: send the previous response's nextCursor back verbatim. It is the compound form <iso-timestamp>_<row-id>, keyed on publishedAt plus the post id, so do not rebuild it from a post's publishedAt alone (see Pagination). Note the contrast with the Journal API, whose entries cursor is an ISO date (YYYY-MM-DD) because entries are keyed by day. Passing a bare date here is a 400, not a silent empty page. nextCursor is null on the last page. /posts/:slug/versions pages on versionNum instead, like the journal's. A version number — as :num, as ?cursor=, or as target_version_num on a revert — is bounded to 1..2147483647, matching the int4 column; past that you get a 400 rather than the 500 Postgres used to raise (SHAN-529).
Sending a valid Authorization header on any read only ever widens what you see, never narrows it: it adds your own drafts.
prev and next (SHAN-495)#
The single-post read carries the post's chronological neighbours so a reader can walk the archive without going back to the index. Each is {slug, title}, or null at that end of the archive; both are always present as keys, never omitted.
Neighbours are ordered by (published_at, id), and the id breaks ties, so two posts sharing a timestamp are still reachable from one another instead of both claiming the same neighbour. prev is the older post and next the newer one, which is the opposite of the list endpoint's newest-first order, so do not assume next means "the next row you would page to".
Unlike the rest of this endpoint, these two fields do not widen for an authenticated caller: neighbours are published posts only, even when the post you asked for is your own draft. Drafts have no place in a chain a stranger can follow.
#
All writes require entries:write and share PAT bucket blog-write (30/min). JWT browser sessions bypass the limit. Every mutation is author-scoped: a post you did not write is a 403 on edit and revert, and a 404 on delete.
| Method | Path | Body | Notes |
|---|---|---|---|
| POST | /posts | {title, content, tags?, status?, cover_image_url?} | Slug generated from the title. content 1..100k markdown, tags max 10 at 40 chars each, status defaults to published. 201 with {post, currentVersionNum: 1} |
| PATCH | /posts/:slug | any of {title, content, tags, status, cover_image_url} | See the split below. Empty object is a 400 |
| POST | /posts/:slug/revert | {target_version_num} | Re-appends that version's title and body as a new version tagged revert. 404 if the target does not exist |
| DELETE | /posts/:slug | none | 204, soft delete to trashed |
The PATCH split#
One route, two behaviours, and the difference decides whether you need an If-Match header:
titleorcontentin the patch is a content edit. It mints a new version, bumpseditCount, and requiresIf-Match: <currentVersionNum>. Omit it and you get a 428. A stale value gets a 409 carrying{currentVersionNum}so you can rebase and retry. Send only one of the two and the other is carried forward from the current version unchanged.tags,statusorcover_image_urlalone is metadata. No version, noIf-Match, noeditCountbump.
A patch may mix both; the content edit runs first, and the metadata update follows.
Remember that If-Match has to be in the CORS allow-headers list to reach the handler from a browser. It already is, for the journal.
From a browser, send X-If-Match instead (SHAN-487)
X-If-Match is accepted everywhere If-Match is, and browsers must use it. Requests from shanejli.com are same-origin and ride a rewrite through Vercel's edge, which evaluates a real If-Match against the response's ETag. Every 200 JSON response from this API carries a weak ETag, and a weak validator can never satisfy If-Match, which requires strong comparison. The edit committed at the origin and the browser still got 412 PRECONDITION_FAILED back — a save that succeeded, reported as a failure, so a retry wrote it twice. Error responses carry no ETag, which is why 409 and 428 were unaffected.
If you call this API directly (a PAT plus curl against the Railway origin), If-Match is fine and remains the documented header. If-Match wins if you somehow send both.
Cover images (SHAN-487)#
cover_image_url is the post's cover art: the image on its masonry tile and the hero above the body. It is metadata, not content — swapping a cover does not mint a version, and a revert to an older version leaves the current cover in place.
Upload the bytes through the journal's uploader, POST /api/journal/images (multipart file, 5MB, image/*), and store the url it returns. There is deliberately no second upload path for the blog. That endpoint needs journal membership, but GET /api/journal/images/:id is public, so covers render for anonymous readers.
Accepted values, capped at 500 chars:
- a relative
/api/journal/images/<uuid>path — what the uploader returns, stored relative so it keeps resolving if the backend origin moves - an absolute
https://URL for art hosted elsewhere
Anything else is a 400: http:, data:, javascript:, and any other same-origin path (a cover must not be able to point at an arbitrary backend route). Send null to remove a cover; omit the field to leave it untouched. A blank string is treated as null.
Comments and reactions (SHAN-488)#
The social layer. This is where the blog diverges hardest from the Journal API: reads are anonymous and writes are open to any signed-in user, not just the post's author. There is no membership gate on any of these routes.
Both live under the post they belong to, and both resolve that post through the same visibility rules as every other read — so a draft or a trashed post 404s here too, and its thread stays private.
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | /posts/:slug/comments | none | {comments}, oldest first, each with an author object |
| POST | /posts/:slug/comments | comments:write | {content}, 1..10k markdown. 201 with {comment} |
| PATCH | /comments/:id | comments:write | {content}. Comment author only; anyone else gets a 404 |
| DELETE | /comments/:id | comments:write | 204. Comment author or the post's author, so the author can moderate their own page |
| GET | /posts/:slug/reactions | none | {summary, mine}. summary is [{emoji, count}]; mine is empty for an anonymous caller |
| POST | /posts/:slug/reactions | reactions:write | {emoji}, toggles. Returns `{result: "added" |
Comments use PAT bucket blog-comments-write (30/min) and reactions blog-reactions-write (60/min) — separate from blog-write, so a chatty commenting token cannot exhaust the author's publishing budget. JWT browser sessions bypass both.
Two shape notes, both deliberate:
- Comments are flat. There is no
parent_comment_id, unlike journal comments, which thread one level deep. A public post's thread is mostly the author answering readers, which reads better chronologically — and comments are hard-deleted, so threading would mean deciding what happens to a reply whose parent is gone. - Reactions attach to posts, not comments. The journal has per-comment reactions because a journal thread is a two-person conversation where a thumbs-up is a reply. On a public post the reaction that carries signal is the one on the post.
emoji comes from the same site-wide vocabulary as journal reactions: +1, -1, laugh, heart, hooray, rocket, eyes, confused. Anything else is a 400. One row per (user, post, emoji): posting the same emoji twice removes it.
Every post carries a commentCount on the list and detail payloads, maintained alongside the comments themselves so the index can show a count without a query per tile.
Reading time (SHAN-541)#
Every post also carries a wordCount on both payloads: the words in its current body with markdown control tokens excluded, recounted whenever the body is written. Divide by 225 and round up for a reading time.
Use it rather than counting contentExcerpt yourself. That field is capped at 500 characters — about 80 words — so a reading time derived from it reports about one minute for every post no matter how long the post is. wordCount is 0 for a post whose body is nothing but markup, and for a row written before the column existed and not yet backfilled; render nothing in that case rather than a floored minimum.
The count is not a whitespace split. A fenced code block contributes nothing, heading, list and blockquote markers are not words, and a link or image contributes its text and never its URL — which is also why it is served rather than computable from the body in SQL.
Feeds (SHAN-491)#
The blog is subscribable. Both feeds live on the frontend, not the API, because they are reader-facing documents rather than JSON endpoints:
| URL | Format | Content type |
|---|---|---|
| https://shanejli.com/blog/feed.xml | RSS 2.0 | application/rss+xml |
| https://shanejli.com/blog/feed.json | JSON Feed 1.1 | application/feed+json |
They carry the 50 newest published posts, newest first, with the full body rendered to HTML (content:encoded in RSS, content_html in JSON Feed) plus a plain-text excerpt, tags, author, and the cover image where one exists. Both are cached for an hour, so a post shows up in a reader within about that long.
The feeds are built from the same anonymous GET /posts and GET /posts/:slug documented above, with no Authorization header — which is precisely why drafts cannot leak into them. That is also why there is no journal equivalent: the journal is invite-only, a feed pull carries no viewer to check membership against, and /journal/feed.xml and /journal/feed.json return 404 on purpose (see Journal API).
Mermaid blocks in a post body arrive in the feed as fenced code, not diagrams — the mermaid upgrade is client-side and there is no client in a feed reader. Upload a rendered image instead when the diagram has to survive the trip; see Images API.
Both URLs are advertised from <head> on /blog and on every post page as <link rel="alternate">, so a reader that accepts a page URL will find them on its own.
Example#
# Publish
curl -X POST https://shanebackend-production.up.railway.app/api/blog/posts \
-H "Authorization: Bearer $PAT" -H "Content-Type: application/json" \
-d '{"title":"On Slow Mornings","content":"# On Slow Mornings\n\n...","tags":["essays"]}'
# 201 {"post":{"slug":"on-slow-mornings",...},"currentVersionNum":1}
# Edit the body (concurrency-checked)
curl -X PATCH https://shanebackend-production.up.railway.app/api/blog/posts/on-slow-mornings \
-H "Authorization: Bearer $PAT" -H "Content-Type: application/json" -H "If-Match: 1" \
-d '{"content":"# On Slow Mornings\n\nRewritten."}'
# 200 {"post":{...},"currentVersionNum":2}
# Read it back with no credentials at all
curl https://shanebackend-production.up.railway.app/api/blog/posts/on-slow-mornings
# Comment on it as a different signed-in user
curl -X POST https://shanebackend-production.up.railway.app/api/blog/posts/on-slow-mornings/comments -H "Authorization: Bearer $PAT" -H "Content-Type: application/json" -d '{"content":"This is the one that made me subscribe."}'
# 201 {"comment":{"id":"...","content":"This is the one that made me subscribe."}}
# React, then react again to take it back
curl -X POST https://shanebackend-production.up.railway.app/api/blog/posts/on-slow-mornings/reactions -H "Authorization: Bearer $PAT" -H "Content-Type: application/json" -d '{"emoji":"heart"}'
# 200 {"result":"added"}
# Read the thread with no credentials at all
curl https://shanebackend-production.up.railway.app/api/blog/posts/on-slow-mornings/comments