Search documentation

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

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

BlockLibrary?Recommended package
paragraph, heading, list, quoteinline Markdown parser onlymarked (or react-markdown, markdown-it)
imageoptionalnext/image on Next.js; plain <img> everywhere else
coderecommendedshiki (SSR-friendly) or prism-react-renderer
video_embedoptionalreact-player (multi-provider) or a raw <iframe>
tweet_embedrecommendedreact-tweet (static, SSR-friendly)
faqoptional@radix-ui/react-accordion or @headlessui/react
callout, cta, key_takeaways, dividernonestyled 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.

ParserBest forNotes
markedSmallest footprint, plain DOMmarked.parseInline(text) returns inline HTML. Pair with DOMPurify.
react-markdownReact-first, JSX outputDisable block plugins for inline-only behaviour. Heavier; brings unified/remark along.
markdown-itPlugin ecosystemmd.renderInline(text). Reach for it if you already standardise on it elsewhere.
unified + remarkCustom AST passesMost 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 generateHTML output 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 a Node.create({ name: 'callout', ... }) for each. At that point you've written almost the same per-block component, just inside a TipTap wrapper.
  • @tiptap/html is the SSR-compatible package; @tiptap/react's EditorContent is 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.

On this page