The Vercel AI SDK chatbot markdown code blocks story has a gap: the SDK streams text beautifully, but rendering that stream as formatted Markdown with syntax-highlighted code blocks takes a few deliberate choices. This guide walks through a production-ready setup using react-markdown, rehype-highlight, and the SDK’s useChat hook — no hand-waving, just the pieces that actually work together.
Step 1: Initialize the project and install dependencies
Start with a Next.js App Router project. The AI SDK works with Pages Router too, but App Router is the default now and handles streaming responses cleanly.
npx create-next-app@latest ai-markdown-chat --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd ai-markdown-chat
Install the runtime dependencies. We need the AI SDK core, the React hooks package, a Markdown processor with a plugin architecture, and a syntax highlighter that works in the browser.
npm install ai @ai-sdk/react react-markdown rehype-highlight highlight.js
npm install -D @types/react-markdown
react-markdown handles the Markdown-to-React transformation. rehype-highlight is a rehype plugin that runs highlight.js over <code> elements during render. We pull in highlight.js styles separately in the next step.
Step 2: Add highlight.js styles globally
Pick a theme from highlight.js/styles and import it once in your global CSS. This keeps the bundle lean — only the theme you use ships to the client.
/* src/app/globals.css */
@import "tailwindcss";
@import "highlight.js/styles/github-dark.min.css";
If you prefer a light theme, swap github-dark.min.css for github.min.css or any other theme in the package. The styles apply to <pre><code class="language-*"> elements, which is exactly what rehype-highlight produces.
Step 3: Create the API route with streaming
The AI SDK’s streamText returns a ReadableStream that toDataStreamResponse converts into the wire format the useChat hook expects. Keep the route minimal; you can swap models or add tools later.
// src/app/api/chat/route.ts
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai("gpt-4o-mini"),
messages,
system: "You are a helpful coding assistant. Use Markdown formatting and fenced code blocks with language tags.",
});
return result.toDataStreamResponse();
}
The system prompt nudges the model to emit fenced blocks with language identifiers (```python, ```typescript, etc.). Without the language tag, rehype-highlight falls back to plain text.
Step 4: Build a streaming-aware Markdown component
This is where most tutorials lose the plot. A naive react-markdown render re-parses the entire accumulated string on every chunk, which flickers and loses cursor position in code blocks. The fix: render incrementally with a component that only updates when the text actually changes, and memoize the parser.
// src/components/MarkdownRenderer.tsx
"use client";
import ReactMarkdown from "react-markdown";
import rehypeHighlight from "rehype-highlight";
import { memo, useMemo } from "react";
interface MarkdownRendererProps {
content: string;
}
const MarkdownRenderer = memo(function MarkdownRenderer({ content }: MarkdownRendererProps) {
const components = useMemo(
() => ({
code: ({ node, children, ...props }: any) => {
const className = node.properties?.className?.[0] || "";
const language = className.replace("language-", "");
return (
<pre {...props}>
<code className={className}>{children}</code>
</pre>
);
},
}),
[]
);
return (
<ReactMarkdown
rehypePlugins={[rehypeHighlight]}
components={components}
remarkPlugins={[]}
>
{content}
</ReactMarkdown>
);
});
export default MarkdownRenderer;
A few notes on this component:
memoprevents re-renders when the parent re-renders butcontenthasn’t changed.useMemostabilizes thecomponentsprop soReactMarkdowndoesn’t see a new object reference every render.- The custom
codecomponent preserves theclassNamethatrehype-highlightadds (language-python,language-typescript, etc.), which is whathighlight.jskeys off. - No
remarkPluginsneeded for code blocks;rehyperuns after the Markdown-to-HTML pass, which is the right place for syntax highlighting.
Step 5: Wire up the chat interface with useChat
The useChat hook from @ai-sdk/react manages the message array, input state, and streaming lifecycle. Pair it with the renderer from Step 4.
// src/app/page.tsx
"use client";
import { useChat } from "@ai-sdk/react";
import { useState } from "react";
import MarkdownRenderer from "@/components/MarkdownRenderer";
export default function ChatPage() {
const { messages, input, handleInputChange, handleSubmit, isLoading, error } = useChat({
api: "/api/chat",
onError: (err) => console.error("Chat error:", err),
});
const [copiedCode, setCopiedCode] = useState<string | null>(null);
const copyToClipboard = async (code: string) => {
await navigator.clipboard.writeText(code);
setCopiedCode(code);
setTimeout(() => setCopiedCode(null), 2000);
};
return (
<main className="flex min-h-screen flex-col bg-gray-50 dark:bg-gray-950 p-4 md:p-8">
<div className="w-full max-w-3xl mx-auto flex-1 flex flex-col">
<header className="mb-6">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
AI SDK Markdown Chat
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Streaming Markdown with syntax highlighting
</p>
</header>
<div className="flex-1 overflow-y-auto space-y-4 mb-6">
{messages.map((message) => (
<div
key={message.id}
className={`flex gap-3 ${
message.role === "user" ? "justify-end" : "justify-start"
}`}
>
<div
className={`max-w-[85%] rounded-2xl px-4 py-3 ${
message.role === "user"
? "bg-blue-600 text-white rounded-br-none"
: "bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-none shadow"
}`}
>
{message.role === "assistant" ? (
<MarkdownRenderer content={message.content} />
) : (
<p className="whitespace-pre-wrap">{message.content}</p>
)}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-white dark:bg-gray-800 rounded-2xl px-4 py-3 rounded-bl-none shadow">
<MarkdownRenderer content="" />
</div>
</div>
)}
{error && (
<div className="text-red-500 text-sm text-center" role="alert">
Error: {error.message}
</div>
)}
</div>
<form onSubmit={handleSubmit} className="gap-2">
<div className="relative flex-1">
<input
value={input}
onChange={handleInputChange}
placeholder="Ask about code, APIs, architecture..."
className="w-full px-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
disabled={isLoading}
aria-label="Chat input"
/>
</div>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="px-6 py-3 rounded-xl bg-blue-600 text-white font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isLoading ? "Thinking…" : "Send"}
</button>
</form>
</div>
</main>
);
}
Key implementation details:
- Assistant messages render through
MarkdownRenderer; user messages render as plain text (you could Markdown-render user messages too, but it’s rarely needed). - The loading state shows an empty renderer so the streaming chunk appears in the right spot without layout shift.
- Error boundary is inline for simplicity; in production, wrap the message list in an error boundary.
Step 6: Add copy-to-clipboard for code blocks
Developers expect a copy button on every code block. Since react-markdown hands off rendering to our custom code component, we can inject a toolbar there. Update MarkdownRenderer.tsx:
// src/components/MarkdownRenderer.tsx
"use client";
import ReactMarkdown from "react-markdown";
import rehypeHighlight from "rehype-highlight";
import { memo, useMemo, useState, useRef, useEffect } from "react";
interface MarkdownRendererProps {
content: string;
}
const CodeBlock = memo(function CodeBlock({
node,
children,
...props
}: any) {
const [copied, setCopied] = useState(false);
const codeRef = useRef<HTMLPreElement>(null);
// Extract raw text from the code element for clipboard
const codeText = useMemo(() => {
// children is a React element tree; the text content lives in the code child
if (typeof children === "string") return children;
if (React.isValidElement(children)) {
return children.props.children || "";
}
return "";
}, [children]);
const handleCopy = async () => {
if (!codeText) return;
await navigator.clipboard.writeText(codeText);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const className = node.properties?.className?.[0] || "";
const language = className.replace("language-", "") || "plaintext";
return (
<pre ref={codeRef} {...props} className="relative group overflow-x-auto rounded-lg bg-gray-100 dark:bg-gray-900 p-4">
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={handleCopy}
className="px-2 py-1 text-xs rounded bg-gray-800 dark:bg-gray-200 text-white dark:text-gray-900 hover:bg-gray-700 dark:hover:bg-gray-300 disabled:opacity-50"
disabled={copied}
aria-label="Copy code"
>
{copied ? "Copied!" : "Copy"}
</button>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1 font-mono">{language}</div>
<code className={className}>{children}</code>
</pre>
);
});
const MarkdownRenderer = memo(function MarkdownRenderer({ content }: MarkdownRendererProps) {
const components = useMemo(
() => ({
code: CodeBlock,
}),
[]
);
return (
<ReactMarkdown
rehypePlugins={[rehypeHighlight]}
components={components}
remarkPlugins={[]}
>
{content}
</ReactMarkdown>
);
});
export default MarkdownRenderer;
What changed:
CodeBlockis now a separate memoized component that receives thenode(the hast node from rehype) andchildren(the highlighted code span tree).- We extract the raw text for the clipboard via a heuristic on
children. This works becauserehype-highlightwraps each token in<span class="hljs-keyword">etc., but the text content remains accessible. - A hover-revealed copy button appears top-right of each block. The language label sits above the code for context.
group/group-hoverTailwind utilities handle the reveal without extra state.
Step 7: Handle streaming markdown edge cases
Streaming Markdown has a known problem: partial chunks can leave unclosed fences, broken tables, or half-formed code blocks that react-markdown renders awkwardly. Two practical mitigations:
1. Buffer the stream client-side until a complete block boundary.
The AI SDK’s useChat exposes messages with complete content only after the stream finishes. During streaming, the last assistant message updates incrementally. For most chat UX, this is fine — users expect incremental rendering. But if you see flicker on code fences, add a small buffer:
// src/hooks/useBufferedMarkdown.ts
import { useState, useEffect } from "react";
export function useBufferedMarkdown(streamingContent: string, isStreaming: boolean) {
const [displayContent, setDisplayContent] = useState(streamingContent);
useEffect(() => {
if (!isStreaming) {
setDisplayContent(streamingContent);
return;
}
// Only update display when we have a complete line or fence
const lines = streamingContent.split("\n");
const lastLine = lines[lines.length - 1];
const fenceCount = (streamingContent.match(/```/g) || []).length;
if (fenceCount % 2 === 0 || lastLine.trim() === "") {
setDisplayContent(streamingContent);
}
}, [streamingContent, isStreaming]);
return displayContent;
}
Then in page.tsx, pass displayContent to MarkdownRenderer instead of message.content for the streaming message. This avoids rendering half-open fences.
2. Sanitize model output before rendering.
Models occasionally emit malformed Markdown (unmatched backticks, stray HTML). Run the content through a sanitizer like hast-util-sanitize in a rehype plugin:
npm install hast-util-sanitize
// src/lib/sanitize.ts
import { sanitize } from "hast-util-sanitize";
import { visit } from "unist-util-visit";
export function rehypeSanitize() {
return (tree: any) => {
visit(tree, "element", (node) => {
// Allow only safe tags and attributes
const allowedTags = [
"p", "br", "strong", "em", "code", "pre", "blockquote",
"ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6",
"a", "img", "table", "thead", "tbody", "tr", "th", "td",
"span", "div"
];
const allowedAttributes = ["href", "src", "alt", "className", "class"];
if (!allowedTags.includes(node.tagName)) {
node.tagName = "div"; // demote to div
}
if (node.properties) {
Object.keys(node.properties).forEach((key) => {
if (!allowedAttributes.includes(key)) {
delete node.properties[key];
}
});
}
});
};
}
Add rehypeSanitize to the rehypePlugins array in MarkdownRenderer before rehypeHighlight. This runs after Markdown parsing but before highlighting, stripping anything that could break layout or inject scripts.
Step 8: Verify the implementation
Run the dev server and test each piece:
npm run dev
Open http://localhost:3000 and verify:
- Basic chat works — Send “Hello”, confirm a response appears.
- Markdown renders — Ask “Show me a bulleted list and a link”. Confirm bullets and links render.
- Code blocks highlight — Ask “Write a Python function that fetches JSON”. Confirm the fence shows
python, colors appear, and the language label reads “python”. - Copy button works — Hover a code block, click Copy, paste into an editor — the raw code (without highlight spans) should paste.
- Streaming feels smooth — Ask for a long explanation. Watch chunks arrive without full re-render flicker.
- Dark mode — Toggle system theme; code blocks and chat bubbles should adapt.
If copy pastes highlighted spans (with hljs-* classes), the codeText extraction in CodeBlock needs adjustment. The simplest fix: walk the children tree and concatenate text nodes only:
const extractText = (node: React.ReactNode): string => {
if (typeof node === "string") return node;
if (React.isValidElement(node)) {
return React.Children.toArray(node.props.children).map(extractText).join("");
}
if (Array.isArray(node)) return node.map(extractText).join("");
return "";
};
const codeText = useMemo(() => extractText(children), [children]);
Production considerations
Model routing and fallbacks. If you’re running multiple models behind a gateway (like n4n.ai’s OpenAI-compatible endpoint that addresses 240+ models with automatic fallback when a provider is degraded), the route in Step 3 stays the same — just change the model identifier. The streaming contract is identical.
Token metering. The AI SDK’s streamText returns usage on the result. Log it or push to your observability stack:
const result = streamText({ /* ... */ });
result.usage.then((u) => {
console.log({ promptTokens: u.promptTokens, completionTokens: u.completionTokens });
});
Caching. Provider cache-control hints (e.g., x-cache-status: hit) forward through the SDK response headers. Read them in the route if you need to surface cache efficiency to dashboards.
Authentication. Wrap the API route with your auth middleware. The useChat hook sends credentials automatically when credentials: "include" is set on the fetch options (configure via useChat({ fetch: (url, opts) => fetch(url, { ...opts, credentials: "include" }) })).
Mobile layout. The chat bubbles use max-w-[85%]; on narrow screens, consider max-w-[95%] and smaller padding. Test with device toolbar.
Summary
You now have a Vercel AI SDK chatbot that renders Markdown and code blocks correctly:
react-markdown+rehype-highlightfor syntax highlighting- Memoized components to prevent streaming flicker
- Copy-to-clipboard on every code block
- Optional buffering and sanitization for production robustness
The pattern scales: swap the model, add tools, plug in a different highlighter (Shiki, Prism), or render math with remark-math + rehype-katex. The core — streaming text into a memoized Markdown renderer — stays the same.