The first production chat surface is where frontend engineers discover that "just stream the tokens" hides a real product: message parts, markdown that renders safely mid-stream, stop and regenerate, scroll that anchors without fighting the user, and history that survives a refresh. Build it once, properly, on AI SDK v5 primitives.
Tier: Core · Workflow area: AI features in the client
This playbook ships a chat UI that feels like the good ones — using useChat, SSE streaming and part-based rendering instead of hand-rolled fetch loops and string concatenation.
Skip it if a vendor's embedded widget genuinely covers the requirement; build when the chat must live inside your design system and data model.
ai + @ai-sdk/react) installed; optionally AI Elements or assistant-ui for prebuilt components you restyle.react-markdown + rehype-sanitize, or equivalent) — never dangerouslySetInnerHTML on model output.Stand up useChat with the transport, not a hand-rolled loop. The hook owns message state, streaming status and error state:
const { messages, sendMessage, status, stop, regenerate, error } =
useChat({
transport: new DefaultChatTransport({ api: "/api/chat" }),
});
On the server, stream with streamText(...).toUIMessageStreamResponse() so the wire format is UI messages over SSE. Resist writing your own EventSource parser — the transport handles chunk framing, aborts and errors you have not met yet.
Render parts, not message.content. v5 messages are arrays of typed parts; switch on them:
{message.parts.map((part, i) => {
switch (part.type) {
case "text": return <Markdown key={i}>{part.text}</Markdown>;
case "reasoning": return <ReasoningDisclosure key={i} part={part} />;
case "source-url":return <Citation key={i} part={part} />;
default: return null;
}
})}
This is the seam everything later plugs into: citations render as your link chips with hostnames, reasoning collapses behind a disclosure, unknown part types render as nothing instead of crashing. Markdown must tolerate being incomplete mid-stream (an unclosed code fence should not explode the layout — style the streaming state, and re-render settles it on completion).
Wire the control affordances off status. status moves through submitted → streaming → ready (or error). Map it honestly: show a thinking indicator on submitted, swap send for a stop button while submitted || streaming (calling stop()), offer regenerate() on the last assistant message when ready, and render error as a distinct retryable state — not a toast that evaporates. Disable send while streaming or queue the input; silently dropping a message during a stream is the most-reported chat bug.
Anchor scroll like a terminal, not a marquee. Rules that match user expectations: stick to bottom while streaming only if the user is already at the bottom; the moment they scroll up, stop following and show a "jump to latest" affordance; never yank scroll on new tokens. Implement with a sentinel element plus IntersectionObserver (or take the behavior from AI Elements/assistant-ui's conversation components, which encode exactly this).
Persist and restore history. Give every conversation an id; on the server, use the stream's onFinish to write the final UI messages to your store, and hydrate useChat with messages from your loader on mount. Verify the full cycle: mid-conversation refresh restores the transcript, and a stop() mid-stream persists the partial assistant message rather than losing the turn.
useChat plus part-based rendering is the whole architecture: state machine from the hook, typed parts to your design-system components, SSE from your own endpoint. Everything you add later — tools, generative UI, resumption — extends this seam instead of replacing it.
fetch + ReadableStream: legitimate for non-React stacks; you re-implement message state, framing and aborts — read pb-48 first.img onerror payload).The frontend work is provider-agnostic by construction: useChat talks only to your /api/chat. In restricted shops, point that route at the in-tenancy provider (Bedrock, Azure OpenAI, your gateway) — the client code in this playbook does not change at all. For fully internal tools, the same UI runs against a local model served through any OpenAI-compatible endpoint behind your route.
Conversation transcripts are user data — apply your normal retention, deletion and access rules to the chat store from the first commit, not as a later compliance retrofit.