BXUP Track progress & test yourself — free
Frontend & Mobile · Playbook · Core · ~5 min read
FRONTEND & MOBILE · PLAYBOOK · CORE

First Chat Surface: useChat, SSE and Message Parts

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.

When to use

  • Your product is adding its first chat or assistant surface and you own the frontend.
  • An existing hand-rolled chat (fetch + append to a string) is hitting its ceiling: no tool rendering, broken markdown mid-stream, janky scroll.
  • You need a defensible baseline before the fancier work — generative UI, resumability — lands on top.

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.

Prerequisites

  • A backend chat endpoint you control — typically a Next.js/Node route calling your provider via the AI SDK and returning a UI-message SSE stream (this is also your key-hiding proxy; see pb-50).
  • AI SDK v5 (ai + @ai-sdk/react) installed; optionally AI Elements or assistant-ui for prebuilt components you restyle.
  • A markdown renderer with sanitization decided up front (react-markdown + rehype-sanitize, or equivalent) — never dangerouslySetInnerHTML on model output.
  • A persistence decision: which store owns conversation history (DB via your API), and what an anonymous user gets.

The workflow

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

  2. 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).

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

  4. 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).

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

Key Takeaway:

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.

Tool variants

  • AI Elements: shadcn-style chat components (conversation, message, reasoning, sources, prompt input) you own and restyle — fastest path that still ends in your code.
  • assistant-ui: richer runtime with primitives for threads, branching and attachments; heavier, but strong when chat is the product.
  • Hand-rolled on fetch + ReadableStream: legitimate for non-React stacks; you re-implement message state, framing and aborts — read pb-48 first.
  • Other frameworks: the SDK ships Vue/Svelte bindings with the same message-part model; the design of this playbook transfers.

Verification

  • Streaming renders progressively with markdown intact mid-stream — test with a prompt that emits a long code block and a table.
  • Stop halts tokens within a beat, persists the partial message, and regenerate replaces the last assistant turn without duplicating it.
  • Scroll: streaming follows at bottom, never steals position after the user scrolls up, and the jump-to-latest affordance appears.
  • Refresh mid-conversation restores history; a network drop mid-stream surfaces the error state with retry, not a frozen spinner.
  • Model output containing raw HTML/script renders inert (sanitization verified with a hostile fixture, e.g. an img onerror payload).

Pitfalls

  • String-concatenation rendering. Treating the reply as one growing string forecloses tools, citations and reasoning; parts are the contract from day one.
  • Markdown re-parse jank. Re-parsing the full transcript per token stutters on long replies; memoize per message and let only the streaming message re-render (pb-48 goes deeper).
  • Scroll hijacking. Forcing scroll-to-bottom on every chunk is the fastest way to make users hate the surface.
  • Toast-only errors. A failed generation must leave a retryable artifact in the transcript; ephemeral errors strand the user's prompt.
  • Unsanitized markdown. Model output is untrusted input; one system-prompt injection away from XSS if you render it raw (pb-50).
  • Persisting only completed turns. Losing the user's message when generation fails teaches users to copy their prompts before sending — a trust killer.

Restricted-environment variant

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.

Note

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.

Success criteria

  • The chat surface ships inside your design system with streaming, stop/regenerate, citations, anchored scroll and durable history — and survives the verification list above on a slow connection.
  • Adding a new part type (a tool result, a file) is a switch-case addition, not a rewrite.
  • No XSS, no lost user turns, and no scroll complaints in the first month of production feedback.
  • The team's next AI surface starts from this component set and lands in days, not weeks.
This is the free reading view. Open BXUP to track your progress, run the hands-on labs, and prove it with original, exam-pattern assessments.
© Anvi Innovate · BXUP — Be Exponentially Upskilled · BXUP is an independent learning platform, not affiliated with or endorsed by AWS, Microsoft, Google, NVIDIA, IBM, Salesforce, Databricks, Oracle or Anthropic. All trademarks belong to their respective owners. · bxup.in