Dilip Singh logo
All posts
Web DevelopmentIntermediate2026-03-27·11 min read

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

tsx
// 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

tsx
'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([]) const [input, setInput] = useState('') const [streaming, setStreaming] = useState('') const [pending, startTransition] = useTransition()

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 (

{messages.map((m, i) => )} {streaming && } setInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && onSend()} disabled={pending} />
) } ```

Mid-Stream Markdown Rendering

Naive approach (re-render markdown on every token) is laggy. Memoize blocks:

tsx
import { memo } from 'react'
import ReactMarkdown from 'react-markdown'

const MarkdownBlock = memo(function MarkdownBlock({ text }: { text: string }) { return {text} }, (prev, next) => prev.text === next.text) ```

Smooth Cursor During Streaming

tsx
function StreamingText({ text }: { text: string }) {
  return (
    <span>
      {text}
      <span className="animate-pulse">▋</span>
    </span>
  )
}

Auto-Scroll That Doesn't Hijack

tsx
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.

DS
Dilip Singh
Lead Software Architect · Hureka Technologies

14+ years building enterprise software and AI systems. Architecting multi-agent AI platforms, RAG pipelines, voice AI, and high-performance SaaS for global clients.