Rendering content
Turn a post's bodyJson block array into rendered output — every block type, image URL enrichment, inline Markdown, table of contents, related posts.
Clog stores post and page bodies as arrays of typed blocks. Each block is a discriminated union keyed by a type string. To render a post, you walk the array and dispatch on type.
This page covers what the 13 block types look like on the wire, how image blocks come pre-enriched with public URLs, how inline formatting works, and the derived helpers (tocItems, readingTimeMin, relatedPosts) you get for free.
The minimal renderer
In JSX, a post body is just bodyJson.map(renderBlock):
function renderBlock(block: Block, i: number) {
switch (block.type) {
case 'paragraph': return <p key={i}><Inline>{block.text}</Inline></p>;
case 'heading': return <Heading key={i} level={block.level} id={block.id}>{block.text}</Heading>;
case 'list': return <List key={i} style={block.style} items={block.items} />;
case 'image': return block.url ? <img key={i} src={block.url} alt={block.alt} /> : null;
case 'code': return <pre key={i}><code className={`language-${block.language}`}>{block.code}</code></pre>;
case 'quote': return <blockquote key={i}><Inline>{block.text}</Inline>{block.attribution && <cite>{block.attribution}</cite>}</blockquote>;
case 'divider': return <hr key={i} />;
case 'callout': return <Callout key={i} variant={block.variant} title={block.title}><Inline>{block.body}</Inline></Callout>;
case 'video_embed': return <VideoEmbed key={i} url={block.url} title={block.title} />;
case 'tweet_embed': return <TweetEmbed key={i} url={block.url} />;
case 'faq': return <FAQ key={i} items={block.items} />;
case 'cta': return <CTA key={i} {...block} />;
case 'key_takeaways': return <KeyTakeaways key={i} items={block.items} />;
}
}Type narrowing on the discriminated type field gives you exhaustive checks for free.
The 13 block types
Every block has a type field; the remaining fields depend on the type. Sizes and constraints are enforced by the API on save.
type | Shape (response) | Notes |
|---|---|---|
paragraph | { text: string } | Up to 10,000 chars. Inline Markdown — see below. |
heading | { level: 2 | 3 | 4, text, id? } | id is auto-derived (kebab-case) from text when missing. Duplicates are disambiguated with a numeric suffix. Feeds tocItems. |
list | { style: 'bullet' | 'ordered', items: string[] } | Each item supports inline Markdown. |
image | { mediaId, alt?, caption?, url: string | null } | url is pre-resolved on every read. See image URL enrichment. |
code | { language: string, code: string } | Code is raw text; render in a <pre><code> with the language hint. |
quote | { text, attribution? } | Inline Markdown in text. |
divider | {} | A horizontal rule / section break. |
callout | { variant: 'info' | 'warning' | 'success' | 'error', title?, body } | Inline Markdown in body (and title). |
video_embed | { url, title? } | YouTube, Vimeo, or any generic video URL. Render in your own player wrapper. |
tweet_embed | { url } | A tweet / X URL. Stamp the X embed script on the page. |
faq | { items: { question, answer }[] } | 1–50 items. Presence drives FAQPage JSON-LD when relevant — see SEO and JSON-LD. |
cta | { title, body, linkUrl, linkLabel, variant: 'default' | 'outlined' } | A generic call-to-action card. |
key_takeaways | { items: string[] } | 1–10 short bullets. Typically rendered at the top of a post as a TL;DR. |
Per-body cap: 1000 blocks. The full machine-readable schemas live in the endpoint reference and the Block types lookup page.
Image URL enrichment
The image block on disk only stores the mediaId — URLs are mutable, IDs are not. On every read response, the backend joins the referenced Media rows and inlines the resolved public url:
| Where | Image block shape |
|---|---|
When you POST / PATCH a body (CreatePostRequest, UpdatePostRequest) | { type: 'image', mediaId, alt?, caption? } — no url. |
When you read a body (PostResponse.bodyJson, PageResponse.bodyJson, structuredBlocks) | { type: 'image', mediaId, alt?, caption?, url: string | null } |
The join is batched — one Media.findMany per response, regardless of how many image blocks the body carries. There are no N+1 round-trips and no per-image follow-up requests.
url can be null if the underlying Media row was deleted after the block was saved. Render a fallback (a placeholder image, or hide the figure) rather than emitting a broken <img src="null">.
function ImageBlock({ block }: { block: Extract<Block, { type: 'image' }> }) {
if (!block.url) return null;
return (
<figure>
<img src={block.url} alt={block.alt ?? ''} loading="lazy" />
{block.caption && <figcaption>{block.caption}</figcaption>}
</figure>
);
}Inline Markdown in prose blocks
Text-bearing block fields — paragraph.text, heading.text, quote.text, list.items[], callout.body, callout.title, quote.attribution — are stored as raw Markdown source strings. The backend does not parse or validate the Markdown contents; it just stores and returns the string.
The dashboard editor serialises inline marks (**bold**, _italic_, [link](url), `code`) to Markdown at save time. Your renderer parses the same Markdown at render time.
Block-level Markdown (headings, lists, code blocks) is not part of inline text — those are their own blocks (heading, list, code). So all you need is an inline-only Markdown parser. A tiny one is fine:
import { marked } from 'marked';
function Inline({ children }: { children: string }) {
// parseInline → no <p> wrappers, no block parsing
return <span dangerouslySetInnerHTML={{ __html: marked.parseInline(children) }} />;
}In Next.js / React Server Components you can render to HTML once at request time and avoid shipping a Markdown parser to the client.
Sanitize the rendered HTML if you allow user-submitted authors or untrusted editors. The Markdown parser does not strip arbitrary HTML by default; pair it with DOMPurify or sanitize-html before injecting.
Derived helpers on the response
A post response carries three helpers the backend recomputes on every write:
| Field | Shape | Use it for |
|---|---|---|
bodyText | string | Plain-text projection of the body (whitespace-joined block text). Use for excerpt generation, search indexing, or character counts. |
tocItems | { level: 2|3|4, text, id }[] | Sidebar table of contents. Walks heading blocks in document order; id matches the corresponding heading.id. |
readingTimeMin | integer | ceil(wordCount / 230), minimum 1. Use directly — no formatting needed beyond "X min read". |
These are also on pages — except tocItems and readingTimeMin (pages don't carry them; only bodyText).
Rendering a table of contents
function TableOfContents({ items }: { items: PostResponse['tocItems'] }) {
if (items.length === 0) return null;
return (
<nav aria-label="On this page">
<ul>
{items.map(item => (
<li key={item.id} style={{ marginLeft: (item.level - 2) * 16 }}>
<a href={`#${item.id}`}>{item.text}</a>
</li>
))}
</ul>
</nav>
);
}Pair with <Heading id={block.id}> so the anchor links land on the right spot.
Embedded relations on a post response
A GET /external/posts/:idOrSlug returns the post plus everything you need to render the article in one round-trip:
| Field | What it is |
|---|---|
author | The full Author row, with avatarMedia nested. Use for byline + headshot. |
category | The full BlogCategory row, with path resolved (the ancestor chain). Use for breadcrumbs. |
featuredMedia | The full Media row for the hero image. |
tags | A flat array of Tag rows. |
relatedPosts | A lightweight summary array (id, slug, title, excerpt, status, featuredMediaId) of pinned related posts, ordered by the workspace's manual sort. |
Each of these is null (or []) when not set. No follow-up /authors/:id or /categories/:id calls are needed.
Pages
Pages have a smaller surface than posts — no author, no category, no featuredMedia, no tags, no relatedPosts, no tocItems, no readingTimeMin. The body is still a typed block array with image URL enrichment:
{
"id": "...",
"slug": "about",
"title": "About",
"bodyJson": [ /* BlockView[] */ ],
"bodyText": "...",
"pageType": "AboutPage",
"status": "published",
"seo": { /* SeoCommon */ },
"jsonLd": { /* @graph */ }
}You can reuse the same block renderer.
Status filtering
By default, list and read endpoints return whichever rows the bound key's permissions can see — including drafts and archived rows if posts:read is granted. If you only want published posts on your public site, filter explicitly:
curl 'https://api.clog.dev/api/v1/external/posts?status=published' -H "X-API-Key: clog_live_xxx"Soft-deleted posts (deletedAt set) are never returned to any read path, even when filtering.
Related
- Reference → Block types — full schema table.
- API → SEO and JSON-LD — the
structuredBlocksprojection (renders with the same block renderer you just wrote). - Examples → Next.js — a complete worked App Router integration with a real renderer.