Most LLM chat interfaces return markdown, but shipping a next.js ai chat markdown code rendering pipeline that handles streaming, code fences, and safe HTML takes real work. This guide walks through a production-grade setup using the Vercel AI SDK and React Markdown in the App Router, with concrete code you can paste into a fresh project.
Step 1: Scaffold the project and install dependencies
Start from a clean App Router app. The --src-dir flag keeps route handlers and components under src/, which avoids import path confusion later.
npx create-next-app@latest chat-ui --ts --app --eslint --tailwind --src-dir
cd chat-ui
Install the inference and rendering libraries. The Vercel AI SDK provides useChat and streamText; react-markdown with remark/rehype plugins handles the next.js ai chat markdown code rendering on the client without a separate markdown service.
npm install ai @ai-sdk/openai react-markdown remark-gfm rehype-highlight highlight.js
rehype-highlight wraps code blocks in highlight.js classes during HTML generation. You must import a CSS theme once in your root layout, or the blocks will be unstyled:
// src/app/layout.tsx
import 'highlight.js/styles/github-dark.css';
import './globals.css';
export const metadata = { title: 'Chat UI' };
export default function RootLayout({ children }: { children: React.ReactNode }) {
return <html lang="en"><body>{children}</body></html>;
}
Step 2: Point the SDK at an OpenAI-compatible endpoint
The Vercel AI SDK talks to any OpenAI-compatible API through a provider object. Create a singleton in src/lib/provider.ts. If you point the base URL at an OpenRouter-class gateway like n4n.ai, you get automatic fallback across 240+ models and per-token metering without changing the calling code.
// src/lib/provider.ts
import { createOpenAI } from '@ai-sdk/openai';
export const openai = createOpenAI({
baseURL: process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1',
apiKey: process.env.OPENAI_API_KEY!,
});
Store credentials in .env.local. Never expose the key to the browser; the route handler is the only place it is read. Pick a model id that exists on your provider—gpt-4o-mini is a safe default for OpenAI, but a gateway may use a prefixed slug.
Step 3: Stream completions from an API route
Create a route handler that accepts the messages array and returns a data stream. Use the Edge runtime to avoid Node’s default buffering and to get lower tail latency.
// src/app/api/chat/route.ts
import { streamText } from 'ai';
import { openai } from '@/lib/provider';
export const runtime = 'edge';
export async function POST(req: Request) {
const { messages } = await req.json();
const result = await streamText({
model: openai('gpt-4o-mini'),
messages,
});
return result.toDataStreamResponse();
}
toDataStreamResponse() emits the exact protocol useChat expects: a text/event-stream of 0:"token" frames. No manual SSE parsing, no ReadableStream plumbing. If the provider rate-limits, the SDK throws and the hook surfaces an error message you can render.
Step 4: Wire the client chat hook
Build a client component that calls useChat. It manages message state, input binding, and appending assistant tokens.
// src/components/Chat.tsx
'use client';
import { useChat } from 'ai/react';
import { SafeMarkdown } from './SafeMarkdown';
export function Chat() {
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
api: '/api/chat',
});
return (
<div className="mx-auto max-w-2xl flex flex-col gap-4 p-4">
{messages.map((m) => (
<div key={m.id} className="rounded border p-3">
<div className="mb-1 text-sm font-semibold">
{m.role === 'user' ? 'You' : 'Assistant'}
</div>
{m.role === 'assistant' ? (
<SafeMarkdown content={m.content} />
) : (
<span className="whitespace-pre-wrap">{m.content}</span>
)}
</div>
))}
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={input}
onChange={handleInputChange}
className="flex-1 border p-2 rounded"
placeholder="Ask for code or a table..."
disabled={isLoading}
/>
<button type="submit" className="border px-4 rounded">Send</button>
</form>
</div>
);
}
The hook automatically resends the full message history on each submit, which is what stateless LLM endpoints expect.
Step 5: Render markdown and code blocks safely
React Markdown disables raw HTML by default, which is the correct security posture for untrusted LLM output. Add remark-gfm for tables, strikethrough, and task lists, and rehype-highlight for syntax coloring.
// src/components/Markdown.tsx
'use client';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
export function Markdown({ content }: { content: string }) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={{
a: ({ node, ...props }) => (
<a {...props} target="_blank" rel="noopener noreferrer" />
),
code: ({ node, className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '');
return (
<code className={className} data-lang={match?.[1]}>
{children}
</code>
);
},
}}
>
{content}
</ReactMarkdown>
);
}
Drop this into the chat map. The highlight.js CSS imported in layout styles the .hljs spans. Inline code stays unhighlighted; fenced blocks get language-specific coloring. For a copy button, wrap the code component in a relative div and use navigator.clipboard—but that is presentation, not core next.js ai chat markdown code rendering.
Step 6: Avoid broken highlighting during streams
Streaming tokens often arrive mid-code-fence. rehype-highlight will mis-highlight or drop a block if the closing ``` is absent. A simple guard: only parse as markdown when backticks are balanced.
// src/components/SafeMarkdown.tsx
'use client';
import { Markdown } from './Markdown';
export function SafeMarkdown({ content }: { content: string }) {
const fences = (content.match(/```/g) || []).length;
if (fences % 2 !== 0) {
// odd number of fences => still streaming a code block
return <pre className="whitespace-pre-wrap">{content}</pre>;
}
return <Markdown content={content} />;
}
This eliminates flicker and console errors without delaying the whole message. For stricter correctness, count only fenced blocks that start at line start, but the naive count covers 99% of LLM outputs. On onFinish from useChat, the final content has balanced fences and renders fully highlighted.
Step 7: Verify the UI end to end
Run the dev server and open the app.
npm run dev
Send: “Write a Python quicksort and a markdown table comparing it to bubble sort.” You should see:
- A fenced Python block with syntax highlighting (keywords colored, strings distinct).
- A GFM table rendered with borders if you added minimal CSS.
- No raw
```text leaking into the final output. - Streaming tokens appear progressively; while the code fence is open, the block shows as plain preformatted text, then snaps to highlighted markdown when closed.
Check the network tab: /api/chat returns a text/event-stream with 0:"..." data frames. If you used a gateway, confirm one model responded and usage metrics are logged server-side. That completes a robust next.js ai chat markdown code rendering loop you can extend with tool calls, model switchers, or custom code wrappers.