Rendering libraries
Targeted libraries for the blocks that benefit from one — syntax highlighting, video and tweet embeds, FAQ accordions — plus the TipTap-as-renderer option, with a complete recommended stack for Next.js.
The Clog block model is intentionally simple — you don't need a heavyweight rendering library to put a post on a page. Most blocks render as a <p>, <h2>, <ul>, or a styled <div>. A handful benefit from a focused helper: syntax highlighting for code, an embed for tweet_embed, a universal player for video_embed, and (optionally) a collapsible primitive for faq.
This page maps every block type to the libraries worth reaching for and how to wire them in. Pair it with Rendering content, which covers the minimal renderer, inline Markdown, and image URL enrichment.
Library matrix at a glance
| Block | Library? | Recommended package |
|---|---|---|
paragraph, heading, list, quote | inline Markdown parser only | marked (or react-markdown, markdown-it) |
image | optional | next/image on Next.js; plain <img> everywhere else |
code | recommended | shiki (SSR-friendly) or prism-react-renderer |
video_embed | optional | react-player (multi-provider) or a raw <iframe> |
tweet_embed | recommended | react-tweet (static, SSR-friendly) |
faq | optional | @radix-ui/react-accordion or @headlessui/react |
callout, cta, key_takeaways, divider | none | styled markup is plenty |
You can ship a perfectly good renderer with just an inline Markdown parser and Shiki. Everything else is incremental polish.
Inline Markdown — pick your parser
Every prose field on a block (paragraph.text, heading.text, quote.text, quote.attribution, list.items[], callout.body, callout.title) carries raw Markdown source. Block-level Markdown is not in scope — headings, lists, and code blocks each have their own block type — so you only need an inline-only parser.
| Parser | Best for | Notes |
|---|---|---|
marked | Smallest footprint, plain DOM | marked.parseInline(text) returns inline HTML. Pair with DOMPurify. |
react-markdown | React-first, JSX output | Disable block plugins for inline-only behaviour. Heavier; brings unified/remark along. |
markdown-it | Plugin ecosystem | md.renderInline(text). Reach for it if you already standardise on it elsewhere. |
unified + remark | Custom AST passes | Most flexible; biggest setup cost. |
The canonical minimal Inline helper that powers the rest of this page lives in Rendering content → inline Markdown; the snippets below assume it exists.
Sanitize unless you fully trust your authors. Markdown parsers do not strip <script> or arbitrary HTML by default — pair with DOMPurify (browser / SSR via isomorphic-dompurify) or sanitize-html (Node) before injecting.
Code blocks — code
The code block is { language: string, code: string }. The cleanest way to render it with syntax highlighting at request time is Shiki — it uses VS Code's TextMate grammars and themes and emits static HTML, no client JS required.
import { codeToHtml } from 'shiki';
export async function CodeBlock({ block }: { block: Extract<BlockView, { type: 'code' }> }) {
const html = await codeToHtml(block.code, {
lang: block.language,
themes: { light: 'github-light', dark: 'github-dark' },
});
return <div className="shiki-wrapper" dangerouslySetInnerHTML={{ __html: html }} />;
}In React Server Components / the Next.js App Router this stays entirely on the server — the rendered HTML ships pre-highlighted. The dual-theme form above gives you light/dark from one call.
Alternatives:
prism-react-renderer— small client-side renderer; reach for it if you don't want a build-time TextMate cost.react-syntax-highlighter— wraps Prism or Highlight.js for React; popular in older apps.
Video embeds — video_embed
The video_embed block is { url: string, title?: string }. For multi-provider support (YouTube, Vimeo, generic MP4, etc.), react-player is the cleanest drop-in:
import ReactPlayer from 'react-player';
export function VideoEmbed({ block }: { block: Extract<BlockView, { type: 'video_embed' }> }) {
return (
<figure style={{ aspectRatio: '16 / 9' }}>
<ReactPlayer
url={block.url}
width="100%"
height="100%"
controls
config={{ youtube: { playerVars: { title: block.title } } }}
/>
</figure>
);
}If you only ever embed one provider, a raw <iframe> with a provider-specific URL transform (https://www.youtube.com/embed/<id>) is smaller and equally fine.
Tweet embeds — tweet_embed
The tweet_embed block is { url: string }. Twitter's own embed script works but is client-side, slow, and causes layout shift. react-tweet from Vercel renders tweets as static HTML on the server — no client JS, no shift, full SSR:
import { Tweet } from 'react-tweet';
export function TweetEmbed({ block }: { block: Extract<BlockView, { type: 'tweet_embed' }> }) {
// Extract the tweet ID from the URL — last path segment, query stripped.
const id = block.url.split('/').pop()!.split('?')[0];
return <Tweet id={id} />;
}react-tweet works in React Server Components on the Next.js App Router out of the box.
FAQ collapsibles — faq
The faq block is { items: { question, answer }[] }. Rendering it as static <h3> + <p> pairs is perfectly fine — Google still picks it up for FAQPage JSON-LD. If you want collapsible UX, an unstyled accessible primitive is the right call:
import * as Accordion from '@radix-ui/react-accordion';
import { Inline } from './inline';
export function FAQ({ block }: { block: Extract<BlockView, { type: 'faq' }> }) {
return (
<Accordion.Root type="single" collapsible>
{block.items.map((item, i) => (
<Accordion.Item key={i} value={`q-${i}`}>
<Accordion.Header>
<Accordion.Trigger>{item.question}</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<Inline>{item.answer}</Inline>
</Accordion.Content>
</Accordion.Item>
))}
</Accordion.Root>
);
}@headlessui/react's <Disclosure> and shadcn/ui's <Accordion> (also built on Radix) are equivalent picks.
Images — image
Image blocks come pre-enriched with a resolved url (see Rendering content → image URL enrichment). On Next.js, next/image adds responsive sizing + lazy loading for free:
import Image from 'next/image';
export function ImageBlock({ block }: { block: Extract<BlockView, { type: 'image' }> }) {
if (!block.url) return null;
return (
<figure>
<Image
src={block.url}
alt={block.alt ?? ''}
width={1200}
height={800}
sizes="(min-width: 768px) 768px, 100vw"
/>
{block.caption && <figcaption>{block.caption}</figcaption>}
</figure>
);
}Add your Clog deployment's media host (the domain of block.url) to next.config.js under images.remotePatterns so the optimizer accepts it.
TipTap as a renderer (when it makes sense)
TipTap is the editor Clog's own dashboard uses to author blocks. A common question from integrators: can I reuse TipTap to render blocks on my site? The honest answer: most of the time, no. The per-block component approach above is simpler, faster, and easier to style. TipTap is an editor framework — using it as a pure renderer means installing every extension and writing a Clog-block → ProseMirror-doc adapter.
It does make sense when:
- You're also letting your users author Clog-shaped content (e.g. a comments or UGC system on top of Clog) — reusing TipTap keeps inline behaviour consistent end-to-end.
- You specifically want
generateHTMLoutput as an HTML string (for emails, RSS feeds you're rendering yourself, or static export). - You want visual parity with the dashboard editor down to the last mark.
The adapter pattern
Clog's BlockView[] is not a TipTap / ProseMirror document — you have to convert. Write a one-time adapter that maps each Clog block to a ProseMirror node, then hand the result + a matching extensions array to generateHTML:
import { generateHTML } from '@tiptap/html';
import StarterKit from '@tiptap/starter-kit';
import Image from '@tiptap/extension-image';
import type { BlockView } from './types';
// One ProseMirror node spec per Clog block. Custom blocks (callout, faq,
// cta, key_takeaways, tweet_embed, video_embed) have no TipTap equivalent —
// you'd write your own `Node.create({ name: 'callout', ... })` for each.
function blocksToProseMirrorDoc(blocks: BlockView[]) {
return {
type: 'doc',
content: blocks.map((block) => {
switch (block.type) {
case 'paragraph':
return { type: 'paragraph', content: [{ type: 'text', text: block.text }] };
case 'heading':
return {
type: 'heading',
attrs: { level: block.level },
content: [{ type: 'text', text: block.text }],
};
case 'list':
return {
type: block.style === 'ordered' ? 'orderedList' : 'bulletList',
content: block.items.map((item) => ({
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: item }] }],
})),
};
case 'image':
return block.url
? { type: 'image', attrs: { src: block.url, alt: block.alt ?? '' } }
: { type: 'paragraph' };
// ...repeat for quote, code, divider, and your custom node specs.
default:
return { type: 'paragraph' };
}
}),
};
}
export function renderWithTiptap(blocks: BlockView[]): string {
return generateHTML(blocksToProseMirrorDoc(blocks), [StarterKit, Image /*, ...custom nodes */]);
}A few honest caveats:
- The inline Markdown in our prose blocks does not convert to TipTap marks automatically — you'd need to parse it into ProseMirror text nodes with marks (
bold,italic,link,code) yourself. The direct rendering path (Markdown parser → HTML) is dramatically less work. - For the custom blocks (
callout,faq,cta,key_takeaways,tweet_embed,video_embed), TipTap doesn't ship an extension — you write aNode.create({ name: 'callout', ... })for each. At that point you've written almost the same per-block component, just inside a TipTap wrapper. @tiptap/htmlis the SSR-compatible package;@tiptap/react'sEditorContentis editor-only and won't run in a Server Component without an editor context.
Unless one of the three reasons above applies to you, skip TipTap and use the per-block components. You'll ship faster, the bundle stays smaller, and styling each block is one CSS file away.
A complete Next.js stack
Putting it all together, here is a realistic recommended dependency set for a Next.js App Router consumer:
npm install marked isomorphic-dompurify shiki react-tweet react-player @radix-ui/react-accordion// PostBody.tsx — a Server Component
import type { BlockView, PostResponse } from './types';
import { Inline } from './inline';
import { CodeBlock } from './CodeBlock';
import { TweetEmbed } from './TweetEmbed';
import { VideoEmbed } from './VideoEmbed';
import { ImageBlock } from './ImageBlock';
import { FAQ } from './FAQ';
import { Callout } from './Callout';
import { CTA } from './CTA';
import { KeyTakeaways } from './KeyTakeaways';
export function PostBody({ post }: { post: PostResponse }) {
return (
<article>
{post.bodyJson.map((block, i) => {
switch (block.type) {
case 'paragraph': return <p key={i}><Inline>{block.text}</Inline></p>;
case 'heading': {
const Tag = `h${block.level}` as const;
return <Tag key={i} id={block.id}><Inline>{block.text}</Inline></Tag>;
}
case 'list':
return block.style === 'ordered'
? <ol key={i}>{block.items.map((it, j) => <li key={j}><Inline>{it}</Inline></li>)}</ol>
: <ul key={i}>{block.items.map((it, j) => <li key={j}><Inline>{it}</Inline></li>)}</ul>;
case 'quote':
return (
<blockquote key={i}>
<Inline>{block.text}</Inline>
{block.attribution && <cite>{block.attribution}</cite>}
</blockquote>
);
case 'divider': return <hr key={i} />;
case 'image': return <ImageBlock key={i} block={block} />;
case 'code': return <CodeBlock key={i} block={block} />;
case 'video_embed': return <VideoEmbed key={i} block={block} />;
case 'tweet_embed': return <TweetEmbed key={i} block={block} />;
case 'callout': return <Callout key={i} block={block} />;
case 'faq': return <FAQ key={i} block={block} />;
case 'cta': return <CTA key={i} block={block} />;
case 'key_takeaways': return <KeyTakeaways key={i} block={block} />;
}
})}
</article>
);
}Because each per-block component lives in its own file, dependencies stay scoped: only pages that actually contain a tweet_embed block pull react-tweet into their bundle, only pages with a video_embed pull react-player, and so on.
Related
- Rendering content — the minimal renderer, inline Markdown, image URL enrichment, derived helpers.
- SEO and JSON-LD —
structuredBlocksis the sameBlockView[]shape and renders with the same component. - Examples → Next.js — a complete App Router worked integration end-to-end.
- Reference → Block types — the full schema table.