Search documentation

Find pages, sections, and snippets across the Clog docs.

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.

typeShape (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:

WhereImage 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:

FieldShapeUse it for
bodyTextstringPlain-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.
readingTimeMinintegerceil(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:

FieldWhat it is
authorThe full Author row, with avatarMedia nested. Use for byline + headshot.
categoryThe full BlogCategory row, with path resolved (the ancestor chain). Use for breadcrumbs.
featuredMediaThe full Media row for the hero image.
tagsA flat array of Tag rows.
relatedPostsA 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.

On this page