Building a Streaming Chat UI with React Server Components
How to build a production chat interface with React Server Components in Next.js 15 — streaming LLM responses, optimistic UI, markdown rendering, code highlighting, and message persistence.
The Streaming Chat UX Bar
Users now expect: tokens appearing as the LLM generates them, smooth markdown rendering mid-stream, syntax-highlighted code, and stop/regenerate buttons that work instantly.
Here is how to build that with React Server Components and the AI SDK.
Server Action for Streaming
// app/actions/chat.ts
'use server'
import { createStreamableValue } from 'ai/rsc'
import { streamText } from 'ai'
import { anthropic } from '@ai-sdk/anthropic'export async function streamChat(history: Message[], userText: string) { const stream = createStreamableValue('')
;(async () => { const { textStream } = streamText({ model: anthropic('claude-sonnet-4-6'), system: 'You are a helpful assistant.', messages: [...history, { role: 'user', content: userText }], })
let buffer = '' for await (const chunk of textStream) { buffer += chunk stream.update(buffer) } stream.done() })()
return { messageStream: stream.value } } ```
Client Component Consuming the Stream
'use client'
import { readStreamableValue } from 'ai/rsc'
import { useState, useTransition } from 'react'
import { streamChat } from '@/app/actions/chat'export function Chat() {
const [messages, setMessages] = useState
async function onSend() { const userMsg = { role: 'user', content: input } const newHistory = [...messages, userMsg] setMessages(newHistory) setInput('')
startTransition(async () => { const { messageStream } = await streamChat(messages, input) let final = '' for await (const chunk of readStreamableValue(messageStream)) { if (chunk) { setStreaming(chunk) final = chunk } } setMessages([...newHistory, { role: 'assistant', content: final }]) setStreaming('') }) }
return (
Mid-Stream Markdown Rendering
Naive approach (re-render markdown on every token) is laggy. Memoize blocks:
import { memo } from 'react'
import ReactMarkdown from 'react-markdown'const MarkdownBlock = memo(function MarkdownBlock({ text }: { text: string }) {
return
Smooth Cursor During Streaming
function StreamingText({ text }: { text: string }) {
return (
<span>
{text}
<span className="animate-pulse">▋</span>
</span>
)
}
Auto-Scroll That Doesn't Hijack
function useAutoScroll(text: string) {
const ref = useRef<HTMLDivElement>(null)
const wasAtBottom = useRef(true)useEffect(() => { const el = ref.current if (!el) return if (wasAtBottom.current) el.scrollTop = el.scrollHeight }, [text])
function onScroll() { const el = ref.current! wasAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 100 } return { ref, onScroll } } ```
Only auto-scroll if the user was already at the bottom — never hijack a user who scrolled up to read.