Chat Message Rendering: Markdown, Code Blocks, Tables, and Rich Content
Build a rich message renderer for AI agent chat interfaces that handles markdown, syntax-highlighted code blocks, tables, and embedded images using React and TypeScript.
The Challenge of Agent Message Content
AI agents produce rich output: code snippets in multiple languages, data tables, mathematical notation, step-by-step instructions with nested lists, and inline references. Rendering this content faithfully in a chat bubble requires a markdown pipeline that handles edge cases gracefully without introducing security vulnerabilities through raw HTML injection.
Setting Up the Markdown Pipeline
The react-markdown library provides a solid foundation. Combine it with remark-gfm for GitHub Flavored Markdown (tables, strikethrough, task lists) and a syntax highlighting library for code blocks.
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
interface MessageRendererProps {
content: string;
}
function MessageRenderer({ content }: MessageRendererProps) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code: CodeBlock,
table: StyledTable,
img: SafeImage,
}}
>
{content}
</ReactMarkdown>
);
}
The components prop lets you override how each markdown element renders. This is where you add syntax highlighting, custom table styles, and image handling.
Syntax-Highlighted Code Blocks
The code component must distinguish between inline code and fenced code blocks. Fenced blocks have a className prop containing the language.
import { ComponentPropsWithoutRef } from "react";
function CodeBlock({
children,
className,
...props
}: ComponentPropsWithoutRef<"code">) {
const match = /language-(\w+)/.exec(className || "");
if (!match) {
return (
<code
className="bg-gray-100 text-red-600 px-1.5 py-0.5
rounded text-sm font-mono"
{...props}
>
{children}
</code>
);
}
const language = match[1];
const codeString = String(children).replace(/\n$/, "");
return (
<div className="relative group my-3">
<div className="flex items-center justify-between
bg-gray-800 text-gray-300 px-4 py-1.5
rounded-t-lg text-xs">
<span>{language}</span>
<CopyButton text={codeString} />
</div>
<SyntaxHighlighter
style={oneDark}
language={language}
PreTag="div"
customStyle={{
margin: 0,
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
}}
>
{codeString}
</SyntaxHighlighter>
</div>
);
}
The Copy Button
Every code block needs a copy button. Implement it with the Clipboard API and visual feedback.
import { useState, useCallback } from "react";
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [text]);
return (
<button
onClick={handleCopy}
className="text-xs text-gray-400 hover:text-white
transition-colors"
>
{copied ? "Copied!" : "Copy"}
</button>
);
}
Styled Tables
Agent responses frequently include comparison tables, data summaries, and feature matrices. Default HTML tables look terrible without styling.
See AI Voice Agents Handle Real Calls
Book a free demo or calculate how much you can save with AI voice automation.
import { ComponentPropsWithoutRef } from "react";
function StyledTable({
children,
...props
}: ComponentPropsWithoutRef<"table">) {
return (
<div className="overflow-x-auto my-3 rounded-lg border">
<table
className="min-w-full divide-y divide-gray-200
text-sm"
{...props}
>
{children}
</table>
</div>
);
}
Add matching overrides for th and td elements with padding, borders, and alternating row colors to complete the table styling.
Safe Image Rendering
Agent responses may reference images. Render them with size constraints and error handling so that broken image links do not break the entire chat layout.
import { useState } from "react";
function SafeImage(props: React.ImgHTMLAttributes<HTMLImageElement>) {
const [error, setError] = useState(false);
if (error) {
return (
<div className="border rounded-lg p-3 text-sm text-gray-500 my-2">
Image could not be loaded
</div>
);
}
return (
<img
{...props}
onError={() => setError(true)}
className="max-w-full h-auto rounded-lg my-2"
loading="lazy"
/>
);
}
Preventing XSS in Rendered Content
react-markdown does not render raw HTML by default, which is the safest behavior. If you enable the rehype-raw plugin to support HTML in agent responses, you must pair it with rehype-sanitize to strip dangerous elements like <script> tags and event handlers. For most agent interfaces, keeping raw HTML disabled is the better choice.
FAQ
How do I handle LaTeX or mathematical notation in agent responses?
Install remark-math and rehype-katex, then add them to the remarkPlugins and rehypePlugins arrays respectively. This renders inline math with single dollar signs ($x^2$) and block math with double dollar signs. Import the KaTeX CSS stylesheet to style the rendered equations.
How do I prevent very long code blocks from making the chat bubble too wide?
The overflow-x-auto class on the code container enables horizontal scrolling when code lines are wider than the bubble. Set word-break: break-all on inline code to prevent long strings without spaces from overflowing. For block code, never set white-space: pre-wrap because it breaks code indentation.
Should I memoize the message renderer?
Yes. Wrap MessageRenderer in React.memo because chat messages are immutable after they are fully streamed. Without memoization, every new message appended to the list causes all previous messages to re-render their full markdown pipeline, which becomes expensive with syntax highlighting across dozens of messages.
#Markdown #SyntaxHighlighting #React #TypeScript #RichContent #AgenticAI #LearnAI #AIEngineering
CallSphere Team
Expert insights on AI voice agents and customer communication automation.
Try CallSphere AI Voice Agents
See how AI voice agents work for your industry. Live demo available -- no signup required.