Skip to content
Chapter 106Lesson 3

useChat, useObject, and the parts array

The Vercel AI SDK React hooks that subscribe to your streaming route and render the answer on screen as it arrives.

The last two lessons built the half of the system the user never sees. The route handler reads the conversation, calls streamText or streamObject, and returns a stream of tokens, which it then pushes out over the wire. Tokens leave the server one at a time, and so far nothing on the other end is catching them.

This lesson builds that other end. The server pushes a stream, and something on the React side has to subscribe to it, accumulate the tokens, and re-render the screen on every chunk so the user watches the answer type itself out. That something is a hook. By the end you’ll be able to wire a streaming chat box and a structured-output form against the exact /api/chat route you already built, with the input box, the live render, the cancel button, and the conversation history all in the right place.

Two ideas carry the whole lesson, and almost everything else follows from them. The first is that your app deals in two different shapes of message, a rich one it stores and renders and a lossy one the model reads, and a single seam converts between them. The second falls straight out of the first: you render message.parts, never message.content. Once you hold those two ideas, the three hooks are each just the right tool for one job.

Two message types: what the app stores versus what the model reads

Section titled “Two message types: what the app stores versus what the model reads”

In the first lesson of this chapter you met a pair of types at the route handler: UIMessage and ModelMessage. You wrote the line that converts one into the other. Now you’re on the other side of that seam, the client side, where the distinction stops being a detail and becomes the thing the whole architecture is built around. It’s worth restating cleanly, even if it’s already half-familiar.

A UIMessage is the rich, app-owned shape. It looks like { id, role, parts }, plus room for metadata, and that parts array can hold prose, reasoning traces, file attachments, and the record of a tool the model called: the full texture of a turn. This is the shape your app stores in the database and renders in React. It’s the conversation as the application understands it.

A ModelMessage is the lossy shape, basically a role and some text, and it’s all the model ever actually reads. The model doesn’t need your message ids or your render metadata. It needs the content, flattened into the format the provider’s API expects.

So the app world and the model world speak different dialects, and exactly one place translates between them. That place is the server, at the route handler, never the client.

App world

stored in the DB · rendered in React

{ id, role, parts }

parts can hold:

text reasoning tool call metadata
convertToModelMessages()
collapse to lossy
server
stream parts back
toUIMessageStreamResponse()

Model world

all the model ever reads

role + text

all flattened to:

text

The two converters live on the server. The client sends UIMessage[] up and receives stream parts down; it never touches ModelMessage.

The two arrows in that figure are the entire contract. On the way to the model, convertToModelMessages(messages) collapses your UIMessage[] down into ModelMessage[] right before the call to streamText, the line you wrote in the first lesson. On the way back, result.toUIMessageStreamResponse() streams parts to the browser in a protocol the client hook knows how to decode. The client itself never calls either converter, so the whole rich-versus-lossy negotiation stays a server concern.

This leads to one rule that you’ll see enforced twice more before the lesson ends: persist UIMessage[], never ModelMessage[]. If you save the lossy shape, you’ve thrown away the tool calls, the reasoning, and the metadata, so the next time the page loads you cannot faithfully rebuild the conversation. The full version is simply gone. This is why, later in this lesson, the persistence write saves the UI shape and nothing else.

The parts array is the render source of truth

Section titled “The parts array is the render source of truth”

Now to that one rendering rule. A v5 message is { id, role, parts }. The interesting field is parts , an array where every element carries a type. The most common is 'text' for prose, but there’s also 'reasoning' for a model’s chain-of-thought trace, 'file' for an attachment, 'tool-<name>' for the record of a tool the model invoked, plus data parts you define yourself. The richness of a turn doesn’t live in one string; it’s spread across that array.

So you render an assistant message by walking the array and switching on each part’s type:

{message.parts.map((part, index) =>
part.type === 'text' ? <span key={index}>{part.text}</span> : null,
)}

In this lesson you only handle 'text', and everything else falls through to null. You might reasonably ask why the array exists at all if you’re only reading one kind of part out of it. The answer arrives in the next chapter: tool calls render as 'tool-<name>' parts, and that’s where the array earns its keep. It isn’t over-engineering, it’s the slot that rich content lands in once the model can do more than talk. For now, hold the shape and render the text.

Here is where the web will mislead you. The internet is full of useChat tutorials written against v4, and a great many of them render the message like this:

{messages.map((message) => (
<div key={message.id}>{message.content}</div>
))}

Drops every tool call, file, and reasoning part. In v5 there is no message.content string. The field is gone, so this renders empty for any message that isn’t pure text, and stops working the moment the model calls a tool.

That message.content field is genuinely gone in v5. Rendering it doesn’t error; it just quietly drops every non-text part. That makes it hard to catch, because it works perfectly in the demo where the model only ever says words. The first time the model calls a tool, half the answer disappears. Seeing the wrong shape labeled as wrong is the point: when you copy a snippet off a blog and it reaches for .content, you’ll recognize it as a v4 leftover.

One small detail in that v5 pane is easy to skip past: list items need a stable key. Use the part’s index within its message, or a part id if you have one, but never a bare array index reused across messages. React keys have to be unique among siblings, so a render that mixes messages will collide.

You follow a useChat tutorial. The chat works fine for plain answers, but the moment the assistant calls a tool, that part of the reply renders as nothing — the bubble is half-empty. The render code is {messages.map((m) => <div key={m.id}>{m.content}</div>)}. What’s wrong?

The render reads a field that only carries plain text and silently ignores everything richer; the tool result lives somewhere this code never looks.

The list is missing a key, so React refuses to render the tool part.

status isn’t being checked, so the component renders before the tool part has finished streaming.

The transport is misconfigured, so tool parts never reach the client at all.

You now have the two facts every hook depends on: the two message types, and the parts render. Now for the main hook. useChat is what you reach for when the surface is a conversation, a back-and-forth that accumulates history. It’s the one wired to the /api/chat route from the first lesson. We’ll build it up in stages, because there are five distinct things happening in one small component, and taking them all at once is what makes the API feel overwhelming.

Start with the call and what it returns:

const { messages, sendMessage, regenerate, status, error, stop } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});

The endpoint is wired through a DefaultChatTransport , not a bare api string handed to the hook. The reason for that is worth a sentence, because it explains the shape. In v5 the hook delegates the actual work of sending to a ChatTransport , an object that knows how to talk to a backend. DefaultChatTransport is the one that speaks plain HTTP POST plus a streaming response against your route, which is exactly what your handler returns. You won’t write a custom transport in this course; the default is the right choice for an HTTP route handler. The shape matters anyway, because it’s the reason the endpoint is a property of the transport rather than a method on the hook.

Here is the change that trips up most people coming from v4. Notice what the hook does not return: there’s no input, no handleInputChange, no handleSubmit. In v4, useChat managed the text in the input box for you. In v5 it doesn’t, and that’s a deliberate subtraction. You own the input the way you’d own any form field, with a plain useState:

const [input, setInput] = useState('');

It helps to reframe this, because “the new version has a new API to memorize” is the wrong way to hold it. A data-fetching hook that also quietly owned your form’s input state was always a slightly leaky abstraction, because it bundled two unrelated jobs. Stripping the input state out didn’t make the hook bigger and more confusing; it made it smaller and clearer. What’s left is a normal React form, exactly the kind you built when you first learned useState for a controlled input. The hook fetches; your component holds the text. That’s the whole change.

That leaves status and stop, which together drive the streaming UX. status is one of 'submitted' | 'streaming' | 'ready' | 'error', a small state machine for the request. The value you key off most is 'streaming': while status === 'streaming', you show the typing indicator, disable the input, and reveal a cancel button. That cancel button calls stop(), which aborts the in-flight request. This closes a loop from the cost work in the previous chapter: the abort propagates through the handler’s abortSignal down to the provider, so cancelling actually stops the model from generating, which stops burning tokens. Without a visible cancel control, a user who gives up on a long answer keeps paying for it, so every chat surface should give them a stop affordance.

Two more renames show up at the call site, worth naming once so they don’t surprise you: what v4 called append is now sendMessage, and what it called reload is now regenerate. regenerate() re-runs the last assistant turn; it’s your “try again” button when an answer comes back wrong.

Here is the whole component. It’s small, so step through it:

'use client';
export const Chat = () => {
const { messages, sendMessage, status, stop } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
const [input, setInput] = useState('');
return (
<div>
{messages.map((message) => (
<div key={message.id}>
{message.parts.map((part, index) =>
part.type === 'text' ? <span key={index}>{part.text}</span> : null,
)}
</div>
))}
<form
onSubmit={(event) => {
event.preventDefault();
sendMessage({ text: input });
setInput('');
}}
>
<input value={input} onChange={(event) => setInput(event.target.value)} />
{status === 'streaming' && <button type="button" onClick={stop}>Stop</button>}
</form>
</div>
);
};

'use client' because this component holds state and handles events, so it can’t be a Server Component. The hook is wired to your route through DefaultChatTransport, and everything below hangs off what it returns.

'use client';
export const Chat = () => {
const { messages, sendMessage, status, stop } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
const [input, setInput] = useState('');
return (
<div>
{messages.map((message) => (
<div key={message.id}>
{message.parts.map((part, index) =>
part.type === 'text' ? <span key={index}>{part.text}</span> : null,
)}
</div>
))}
<form
onSubmit={(event) => {
event.preventDefault();
sendMessage({ text: input });
setInput('');
}}
>
<input value={input} onChange={(event) => setInput(event.target.value)} />
{status === 'streaming' && <button type="button" onClick={stop}>Stop</button>}
</form>
</div>
);
};

The component owns the input, not the hook. This is the biggest v5 change and also the plainest: a controlled input, the same useState form you’d write for any text field. The hook deliberately doesn’t manage this anymore.

'use client';
export const Chat = () => {
const { messages, sendMessage, status, stop } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
const [input, setInput] = useState('');
return (
<div>
{messages.map((message) => (
<div key={message.id}>
{message.parts.map((part, index) =>
part.type === 'text' ? <span key={index}>{part.text}</span> : null,
)}
</div>
))}
<form
onSubmit={(event) => {
event.preventDefault();
sendMessage({ text: input });
setInput('');
}}
>
<input value={input} onChange={(event) => setInput(event.target.value)} />
{status === 'streaming' && <button type="button" onClick={stop}>Stop</button>}
</form>
</div>
);
};

The render walks messages, and for each message walks its parts, drawing the text parts. As tokens stream in, messages updates and this re-runs, which is the answer typing itself out.

'use client';
export const Chat = () => {
const { messages, sendMessage, status, stop } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
const [input, setInput] = useState('');
return (
<div>
{messages.map((message) => (
<div key={message.id}>
{message.parts.map((part, index) =>
part.type === 'text' ? <span key={index}>{part.text}</span> : null,
)}
</div>
))}
<form
onSubmit={(event) => {
event.preventDefault();
sendMessage({ text: input });
setInput('');
}}
>
<input value={input} onChange={(event) => setInput(event.target.value)} />
{status === 'streaming' && <button type="button" onClick={stop}>Stop</button>}
</form>
</div>
);
};

On submit, sendMessage({ text: input }) sends the text part up the transport, then setInput('') clears the box. { text } is the send shape for a text message, and the call is sendMessage, not the v4 name append.

'use client';
export const Chat = () => {
const { messages, sendMessage, status, stop } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
const [input, setInput] = useState('');
return (
<div>
{messages.map((message) => (
<div key={message.id}>
{message.parts.map((part, index) =>
part.type === 'text' ? <span key={index}>{part.text}</span> : null,
)}
</div>
))}
<form
onSubmit={(event) => {
event.preventDefault();
sendMessage({ text: input });
setInput('');
}}
>
<input value={input} onChange={(event) => setInput(event.target.value)} />
{status === 'streaming' && <button type="button" onClick={stop}>Stop</button>}
</form>
</div>
);
};

While the response streams, a Stop button appears, wired to stop(). Without it, a user who navigates away or gives up leaves the model generating, burning tokens for an answer nobody will read.

1 / 1

Three failure modes are worth naming, because each one is a common trap. The first is reaching for input, handleInputChange, or handleSubmit off the hook; they don’t exist in v5, and you own the input with useState. The second is calling append or reload, which were renamed to sendMessage and regenerate. The third is forgetting to wire stop, which leaves an abandoned stream generating, the cost mistake the whole chapter has been guarding against.

Fill the blanks so the chat owns its own input, sends the text part, and can cancel an in-flight stream. Pick the right option from each dropdown, then press Check.

'use client';
export const Chat = () => {
const { messages, status, ___ } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
const [input, setInput] = ___('');
return (
<form
onSubmit={(event) => {
event.preventDefault();
___({ text: input });
setInput('');
}}
>
<input value={input} onChange={(event) => setInput(event.target.value)} />
{status === 'streaming' && (
<button type="button" onClick={stop}>Stop</button>
)}
</form>
);
};

A chat that forgets everything the moment you refresh isn’t a product, so the conversation has to survive a reload. This is where the “two message types” rule pays off, and where a second architectural boundary shows up. Persistence isn’t a footnote on useChat; it’s a distinct decision with a clear right answer, so it earns its own section.

The decision is where the durable write happens, and the answer is server-side, in the handler’s onFinish, not on the client. This is the same discipline the first two lessons established for every write in this chapter: the save lives inside onFinish, on the server, right next to where the token counts and the audit event already land. The handler you already wrote needs one small addition:

return result.toUIMessageStreamResponse({
originalMessages: messages,
onFinish: ({ messages }) => saveChat({ chatId, messages }),
});

Two arguments do the work. originalMessages is the incoming history, the conversation as it stood when the request arrived. The messages handed to onFinish is the full updated UIMessage[] after the assistant’s turn finished streaming. You save that second one, the complete post-answer conversation, in the UI shape. This is the “persist the UI shape, never the lossy shape” rule from the start of the lesson, now showing up as actual code. It’s the same seam you wrote in the first lesson, doing one more thing now.

One subtlety here has caused real production bugs, so it’s worth stating plainly. useChat also exposes its own onFinish on the client: onFinish({ message, messages, isAbort, isDisconnect, isError }). It’s tempting to save from there, but don’t. The client’s onFinish is for UI reactions like pinging analytics, scrolling to the bottom, or popping a toast. It is not a reliable persistence trigger, and the isAbort, isDisconnect, and isError flags are the tell: a user who closes the tab mid-stream never fires it at all. The durable write has to be somewhere that always runs to completion, and that’s the server’s onFinish. Use the client’s for cosmetics, and never trust it with the source of truth.

That covers the write. Now the read, loading the saved conversation on the next visit, which is the second boundary. The chat page is a Server Component: it reads the conversation’s UIMessage[] straight from the database, scoped to the org, and passes them down to the Client Component as a prop.

Server · runs on the server
app/chat/page.tsx Server Component

Renders on the server. No browser, no fetch.

getChat(chatId) Drizzle · org-scoped

Reads UIMessage[] straight from the DB.

messages the only thing that crosses
Client · runs in the browser
<Chat> Client Component · 'use client'

Mounts useChat({ messages }) — the history hydrates, the live stream takes over.

Static shell rendered on the server, live stream rendered on the client. The boundary is one prop hand-off, with no client fetch.

On the client side, the component mounts the hook with that history wired into the messages option:

const { messages, sendMessage, status, stop } = useChat({
messages: initialMessages,
transport: new DefaultChatTransport({ api: '/api/chat' }),
});

One naming trap is worth defusing here. You’ll often see the prop value called initialMessages , which is just a conventional variable name for “the history we loaded.” But the hook option it gets assigned to is messages, not initialMessages. (v4 had an option literally named initialMessages; in v5 it’s messages, and a fair amount of stale documentation still says otherwise.) Name your variable whatever reads well, and assign it to messages.

Notice what this architecture doesn’t do: it doesn’t fetch the history from the client. It would be tempting to mount the component, fire a fetch inside a useEffect, and load the conversation that way, but that’s wrong on two counts. It adds a network round-trip the architecture doesn’t need, since the server was already rendering the page and could just read the rows in-process. And useEffect for fetching server data is a known anti-pattern in this codebase for exactly this reason: the server component is the better fetch point. The page reads the rows, the prop carries them, the hook hydrates, and no client fetch happens.

Step back and the round-trip closes into a clean loop. The handler runs convertToModelMessages before streamText; on completion toUIMessageStreamResponse({ originalMessages, onFinish }) persists the UIMessage[]; the next visit rehydrates from that same UI shape through the Server Component prop. Two message types and one seam, showing up three times: on the way down to the model, on the way back to save, and on the way in on the next load.

useCompletion: single-shot text in, streaming text out

Section titled “useCompletion: single-shot text in, streaming text out”

Not every AI surface is a conversation. Sometimes it’s one box, one button, one streaming answer, and no history to speak of: a “summarize this” widget, a draft generator, an autocomplete drawer. For those, useChat is the wrong tool, because it drags in a whole messages array and a parts render for a surface that has exactly one output and no memory. The right tool is useCompletion, the client twin of the single-shot text call.

It’s deliberately simpler:

const { completion, complete, isLoading, stop, error } = useCompletion({
api: '/api/complete',
});

completion is the streaming string: the answer, growing token by token, ready to drop straight into the page. complete(prompt) fires the request. There’s no messages, no parts, no sendMessage, just a prompt in and a string out. Here is the minimal surface, simple enough that it needs no stepped walkthrough:

'use client';
export const SummaryBox = () => {
const { completion, complete, isLoading } = useCompletion({
api: '/api/complete',
});
const [input, setInput] = useState('');
return (
<div>
<textarea value={input} onChange={(event) => setInput(event.target.value)} />
<button onClick={() => complete(input)} disabled={isLoading}>
{isLoading ? 'Generating…' : 'Summarize'}
</button>
<p>{completion}</p>
</div>
);
};

The route behind it is the same shape you already know, with authedRoute and the quota guard, and one difference in the return helper: it returns result.toTextStreamResponse(), the text protocol, not the parts protocol. That mirrors what you saw in the last two lessons: the handler’s return helper is chosen by the hook on the other end. useChat wants parts, so the handler speaks toUIMessageStreamResponse. useCompletion wants a string, so it speaks toTextStreamResponse. The hook and the helper are two ends of one contract.

The decision comes down to one line: a conversation calls for useChat, and a stateless single-shot calls for useCompletion. Reaching for useChat when there’s no conversation means maintaining a messages array and a parts render that the surface never uses.

useObject: partial objects that fill in as they stream

Section titled “useObject: partial objects that fill in as they stream”

The third hook is the client side of the structured-output work from the last lesson, and it’s the one that pays off the whole “ask the model for typed data, not prose” thread. When the server primitive is streamObject, the client primitive is useObject, and it does something the others can’t: it renders a typed object that fills in field by field as the model parses it.

One import detail comes first, because it will trip you up otherwise. The hook still ships under an experimental name, experimental_useObject , so you import it under an alias:

import { experimental_useObject as useObject } from '@ai-sdk/react';

That’s all there is to it: experimental_useObject, aliased to useObject on the way in, and you never think about it again.

The call mirrors the others, with one prop that ties the two halves of the system together:

const { object, submit, isLoading, stop, error } = useObject({
api: '/api/extract',
schema: invoiceLineItemSchema,
});

That schema is the same Zod object you used on the server: the invoiceLineItemSchema from the last lesson, with its description, quantity, and unitAmount fields. One schema serves both ends of the wire. That’s the same portability win the last lesson described for swapping providers, now paying off across the client/server boundary too: the contract lives in one place, and both sides read from it.

The interesting field is object. Its type is DeepPartial<RESULT> | undefined, and that “deep partial” is the whole point. As the stream parses, object builds up incrementally: first it’s undefined, then it has a description, then a quantity lands, then unitAmount. So you render it conditionally, each piece appearing as it arrives:

'use client';
export const LineItemExtractor = () => {
const { object, submit, isLoading } = useObject({
api: '/api/extract',
schema: invoiceLineItemSchema,
});
const [input, setInput] = useState('');
return (
<div>
<textarea value={input} onChange={(event) => setInput(event.target.value)} />
<button onClick={() => submit(input)} disabled={isLoading}>
Extract line item
</button>
{object?.description != null && <p>{object.description}</p>}
{object?.quantity != null && <span>Qty: {object.quantity}</span>}
{object?.unitAmount != null && <span>{object.unitAmount}</span>}
</div>
);
};

submit(input) kicks it off, and isLoading and stop behave just like they do in the other hooks. The next figure shows what that conditional render buys you: the fields appearing one at a time as the model fills them in.

Line item

1 / 3 fields
Description Annual support plan
Qty
Unit amount
The description lands first: object?.description != null flips, so its <p> renders while quantity and unit amount are still absent.

Line item

2 / 3 fields
Description Annual support plan
Qty 1
Unit amount
Quantity parses next, one more field deep on the same object. The description doesn't move or re-render; the new value just lands in its slot.

Line item

3 / 3 fields
Description Annual support plan
Qty 1
Unit amount $2,400.00
Unit amount completes the object. Each field landed in a stable slot and stayed, so the partial output reads as progress, not a glitch.

There’s a design judgment hiding in that streaming render, and it’s worth pulling out on its own, because the SDK won’t enforce it for you. Partial output has to be useful, not jarring. A chat box reading text as it streams feels natural, since you’re just reading along. But a structured form whose fields appear, change, and reorder as the model second-guesses itself looks broken, as if the page is glitching. The rule is to stream into append-only or stable slots: let a field land and stay, and don’t let a half-parsed value flicker between guesses in front of the user. useObject hands you the partial, and designing the surface so the partial reads as progress rather than malfunction is on you.

Sort each workload into the hook that names it. Drag each item into the bucket it belongs to, then press Check.

useChat Multi-turn, with history
useCompletion One prompt in, one string out
useObject Typed object that fills in as it streams
A multi-turn customer support assistant
A conversation that must survive a page reload
A one-shot summary of a blog post
Draft a tweet from a short brief
Pull invoice line items into a typed shape, fields appearing as they arrive
Turn a paragraph into the fields of a form

Models fail. The provider rate-limits you, a request times out, the user runs through their daily token budget. Every one of these hooks hands you an error and flips status to 'error', so the question isn’t whether you handle failure, it’s what you put on the screen when it happens. The answer has a security edge that makes it worth its own section.

The pattern to build is a retry control plus a sanitized message, something like “the model couldn’t complete that request,” and never the raw error.message. Provider error strings leak vendor identifiers, model names, and occasionally stack fragments. That’s an information disclosure on top of being poor UX. This is the same security baseline the whole course has held: internal and vendor detail never reaches the client.

{status === 'error' && <p>{error.message}</p>}

Leaks the provider’s raw failure to the user. That string can carry the vendor’s name, the model id, even a stack fragment: an information disclosure, and a confusing message besides. The user learns nothing actionable, and you’ve exposed your internals.

There’s a useful consequence of where the friendly text comes from. It isn’t the model’s error string at all; it’s mapped from the handler’s HTTP response, the RFC 9457 Problem Details envelope your route already returns. The client reads the status code, not the body string: a provider 429 or 5xx degrades to “try again in a moment,” and the exhausted daily quota from the cost lesson shows “you’ve used your daily limit, resets at midnight UTC.” The status is the contract; the prose is yours.

This is the quiet payoff of a decision made two lessons ago. The auth, quota, and rate-limit guards all live in the handler precisely so the client has a clean, sanitized HTTP contract to render against, a tidy set of status codes, instead of ever having to read the provider’s raw failure and guess. The handler does the sanitizing; the client just maps codes to friendly copy.

You have every piece now. Here they are stitched into one continuous flow, because the value is in seeing the whole thing as a single loop rather than a pile of parts.

A user opens a saved chat and sends a message. The Server Component reads the conversation’s UIMessage[] from the database and passes it down as a prop. The Client Component mounts useChat with that history and the transport. The user types into a plain useState input and hits send, which fires sendMessage({ text }). That POSTs the UIMessage[] to /api/chat, where the authedRoute guards check identity and quota. The handler runs convertToModelMessages, calls streamText, and streams parts back via toUIMessageStreamResponse. On the client, messages updates chunk by chunk, the render walks parts, and status: 'streaming' drives the typing indicator and the Stop button. When the turn finishes, the handler’s onFinish saves the full UIMessage[] back to the database, server-side, and the next time the page loads, the loop starts over from that saved history.

Here is that flow as a diagram you can scrub through. Each step lands on one seam and names the chapter that owns it, so it doubles as a map of the whole AI section so far.

Browser
page.tsx Server Component shell
messages prop
<Chat> · useChat Client Component
sendMessage
input · useState plain controlled form
parts render walks message.parts · status
POST UIMessage[]
stream parts
Server
/api/chat route handler
authedRoute guards identity · role · rate limit · quota
convertToModelMessages → streamText
toUIMessageStreamResponse + onFinish save
read
save UIMessage[]
database conversation as UIMessage[]
↺ next load resumes from page.tsx
Load: the Server Component reads the saved conversation from the database and passes it down as the messages prop. (Server Components, an earlier chapter.)
Browser
page.tsx Server Component shell
messages prop
<Chat> · useChat Client Component
sendMessage
input · useState plain controlled form
parts render walks message.parts · status
POST UIMessage[]
stream parts
Server
/api/chat route handler
authedRoute guards identity · role · rate limit · quota
convertToModelMessages → streamText
toUIMessageStreamResponse + onFinish save
read
save UIMessage[]
database conversation as UIMessage[]
↺ next load resumes from page.tsx
Mount: the Client Component mounts useChat({ messages, transport }), hydrating the loaded history. (This lesson.)
Browser
page.tsx Server Component shell
messages prop
<Chat> · useChat Client Component
sendMessage
input · useState plain controlled form
parts render walks message.parts · status
POST UIMessage[]
stream parts
Server
/api/chat route handler
authedRoute guards identity · role · rate limit · quota
convertToModelMessages → streamText
toUIMessageStreamResponse + onFinish save
read
save UIMessage[]
database conversation as UIMessage[]
↺ next load resumes from page.tsx
Type and send: the user types into a plain useState input and submits, firing sendMessage({ text }). (Plain React forms, an earlier chapter.)
Browser
page.tsx Server Component shell
messages prop
<Chat> · useChat Client Component
sendMessage
input · useState plain controlled form
parts render walks message.parts · status
POST UIMessage[]
stream parts
Server
/api/chat route handler
authedRoute guards identity · role · rate limit · quota
convertToModelMessages → streamText
toUIMessageStreamResponse + onFinish save
read
save UIMessage[]
database conversation as UIMessage[]
↺ next load resumes from page.tsx
POST: the UIMessage[] goes to /api/chat, where authedRoute checks identity, role, rate limit, and the daily token quota. (Route handlers, plus the cost lesson.)
Browser
page.tsx Server Component shell
messages prop
<Chat> · useChat Client Component
sendMessage
input · useState plain controlled form
parts render walks message.parts · status
POST UIMessage[]
stream parts
Server
/api/chat route handler
authedRoute guards identity · role · rate limit · quota
convertToModelMessages → streamText
toUIMessageStreamResponse + onFinish save
read
save UIMessage[]
database conversation as UIMessage[]
↺ next load resumes from page.tsx
Generate: the handler runs convertToModelMessages, calls streamText, and streams parts back via toUIMessageStreamResponse. (The earlier lessons of this chapter.)
Browser
page.tsx Server Component shell
messages prop
<Chat> · useChat Client Component
sendMessage
input · useState plain controlled form
parts render walks message.parts · status
POST UIMessage[]
stream parts
Server
/api/chat route handler
authedRoute guards identity · role · rate limit · quota
convertToModelMessages → streamText
toUIMessageStreamResponse + onFinish save
read
save UIMessage[]
database conversation as UIMessage[]
↺ next load resumes from page.tsx
Render: messages updates chunk by chunk, the render walks parts, and status: 'streaming' drives the typing indicator and Stop button. (This lesson.)
Browser
page.tsx Server Component shell
messages prop
<Chat> · useChat Client Component
sendMessage
input · useState plain controlled form
parts render walks message.parts · status
POST UIMessage[]
stream parts
Server
/api/chat route handler
authedRoute guards identity · role · rate limit · quota
convertToModelMessages → streamText
toUIMessageStreamResponse + onFinish save
read
save UIMessage[]
database conversation as UIMessage[]
↺ next load resumes from page.tsx
Persist: on completion the handler's onFinish saves the full UIMessage[] to the database, server-side. The next load resumes from the top. (This lesson.)

That loop is the one figure worth keeping from this chapter: two message types, one converting seam, and a persistence cycle that makes the conversation durable. Everything you wrote across the three lessons sits somewhere on it.

To make the ordering stick, put the steps back in order yourself:

Order the seven seams a single message crosses, from page load to the durable save. Drag the items into the correct order, then press Check.

The Server Component reads the saved UIMessage[] from the database and passes it as the messages prop.
The Client Component mounts useChat({ messages, transport }), hydrating the history.
The user types into a useState input and submits, firing sendMessage({ text }).
The UIMessage[] POSTs to /api/chat, where authedRoute checks identity and the token quota.
The handler runs convertToModelMessages, calls streamText, and streams parts back via toUIMessageStreamResponse.
messages updates chunk by chunk; the render walks parts while status: 'streaming' drives the UX.
On completion the handler’s onFinish saves the full UIMessage[] to the database, server-side.

The hooks here are the stable core, but the AI SDK’s UI layer moves fast, so keep these references open while you build. Trust the current docs over any pinned snippet, your own or anyone else’s.