n4nAI

How to render markdown and code blocks in a Next.js chat UI

Step-by-step guide to next.js ai chat markdown code rendering: stream LLM responses and safely display markdown with code highlighting in App Router.

n4n Team3 min read637 words

Audio narration

Coming soon — every post will get a voice note here.

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 &#123; Markdown &#125; from './Markdown';

export function SafeMarkdown(&#123; content &#125;: &#123; content: string &#125;) &#123;
  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:

  1. A fenced Python block with syntax highlighting (keywords colored, strings distinct).
  2. A GFM table rendered with borders if you added minimal CSS.
  3. No raw ``` text leaking into the final output.
  4. 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.

Tagsnextjsmarkdownchat-uivercel-ai-sdk

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All next.js ai chat integration (app router + vercel ai sdk) posts →