Topic 5.4
UIMessage Parts
In one line
A message in the AI SDK is not one string. It is a list of parts, each with a type: text, reasoning, a tool call, a source, a file, your own data, and a marker between steps. You render a message by looping over its parts and choosing a component for each type, and you ignore the types you do not know.
Think of it like this
A tiffin, the stacked lunch box. One box, several compartments: rice, curry, bread, a sweet. You do not eat 'the box'; you open each compartment and treat what is in it as what it is. A message is the tiffin, and its parts are the compartments. You open each one by its label.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Part
- One labelled piece of a message: text, reasoning, a tool call, a source, a file or data.
- Part type
- The
typestring that says what a part is, such astextortool-lookupOrder. - State (of a part)
- Where a part is in its life: for text
streamingordone; for a toolinput-available,output-availableand more. - Reasoning
- Working-out text some models produce before the answer.
- Tool part
- A part for one tool call. It holds the tool's input, and its output once it has run.
- Source part
- A part that names a web page (
source-url) or document (source-document) the answer used. - Data part
- A part whose type starts with
data-. It carries any JSON your own server wrote into the stream. - Transient
- A data part that is not saved in the message and is delivered only to
onData. - step-start
- An invisible marker that begins each step of a multi-step answer. Render nothing for it.
Step by step
01How chunks become parts
Topic 5.2's chunks and this topic's parts are two views of one thing. The hook reads each chunk and updates the last assistant message. A text-start chunk adds a text part in the streaming state, text-delta chunks append to it, and text-end marks it done. The same pattern applies to reasoning. A tool call adds a tool part that later gets its output.
02Render by type
The first live example renders a real message that has four kinds of parts: a step marker, reasoning, a tool call with its result, and text. The renderer is a function with one branch per type and a quiet return null at the end.
After the answer finishes, the example prints what ended up on screen, part by part. The step-start markers print nothing, because they render nothing.
03Watch the parts change
The second example records every distinct shape the last message passes through. You can see the reasoning part appear in the streaming state, finish while the tool part appears as input-available, change in place to output-available, and the text part stream and complete.
04Data and source parts from your server
The third example writes a progress part twice with the same id, a one-off toast marked transient, and a source, before merging the model's own stream. The message ends with one progress part (holding the second value), the source, and the text, and the toast went only to onData.
05Edge cases
The fourth example shows the empty reply (no assistant message, status ready), and a rendering function fed messages in older and unknown shapes. A safe renderer returns nothing for what it does not know, and the screen can tell 'nothing visible' from 'nothing came back'.
Try it yourself
- 1
Show the reasoning open
In the first example change
<details className="reasoning">to<details className="reasoning" open>and run it.The model's scratch work is now in the user's face. Many products keep it collapsed, or drop it with
sendReasoning: falseon the server so it never even leaves your route. - 2
Add a part type to the renderer
In the third example, render the progress part: replace the
useEffectloop with aPartViewlike the first example's, and addif (part.type === "data-progress") return <p>{part.data.step}</p>;to it.The user sees 'Looking up your order' first, and the same paragraph changes in place to 'Found it' when the server writes the same id again, above the answer.
- 3
Break the switch on purpose
In the first example replace the last line of
PartView(return null;) withreturn <p>{part.text}</p>;and run it.The
step-startparts have notext, so two empty paragraphs appear. For a type with an object in it, such as a data part, React would throw 'Objects are not valid as a React child'. This is why unknown types must return nothing.
Code & diagrams
The assistant message has five parts but only three are visible: reasoning (collapsed), the tool line and the text. The two step-start markers rendered nothing. The first on-screen line is the user's own message. This is a full tool round trip with no tool UI work, because the SDK ran the tool on the server and put its result in the same part.
Preview
Console
Nothing logged yet.Expected console output
part types in the message: step-start, reasoning, tool-lookupOrder, step-start, text
on screen: text -> Where is ORD-1042?
on screen: reasoning -> ThinkingThe customer asks about an order, so I should look it up.
on screen: tool-lookupOrder -> Looked up lookupOrder: shipped
on screen: text -> Order ORD-1042 has shipped.Each line is a new shape of the same message. A part is never replaced by a new one: the tool part keeps its place and its state moves forward, so a list keyed by index and by part position stays stable.
Preview
Console
Nothing logged yet.Expected console output
step-start reasoning(streaming)
step-start reasoning(done) tool-lookupOrder(input-available)
step-start reasoning(done) tool-lookupOrder(output-available) step-start text(streaming)
step-start reasoning(done) tool-lookupOrder(output-available) step-start text(done)onData sees every data part, including the transient toast and both progress writes. The stored message keeps one progress part with the latest value, the source, a step marker and the text, and no toast.
Preview
Console
Nothing logged yet.Expected console output
onData: data-progress {"step":"Looking up your order"}
onData: data-progress {"step":"Found it"}
onData: data-toast {"text":"Saved to your history"} (transient)
assistant messages: 1
part data-progress: {"step":"Found it"}
part source-url: Returns policy https://example.com/returns
part step-start
part text: Returns are accepted within 7 days.A message with nothing to read still exists in the list. Decide in one place what 'visible' means, and skip empty bubbles.
Preview
Console
Nothing logged yet.Expected console output
message 1 shows: "Hello!"
message 2 shows: "" -> render nothing, not an empty bubble
message 3 shows: "" -> render nothing, not an empty bubble
message 4 shows: "" -> render nothing, not an empty bubble
empty reply -> status: ready | messages: 1 | last role: user
screen says: Mitra had no answer. Try again.Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Render text for every part
In the first example replace return null; // step-start, data, file, and anything new with return <p>{String(part.text)}</p>; and run it.
Break #2
Write data before the start chunk
In the third example delete writer.write({ type: "start" }); and change result.toUIMessageStream({ sendStart: false }) to result.toUIMessageStream().
Myth vs fact
Myth
A message is just its text, so I can show message.parts[0].text.
Fact
The first part may be a step marker or reasoning. Filter by type; never index into parts.
Myth
A tool call needs a custom client-side handler to show up.
Fact
Tool calls arrive as ordinary parts with their own state, even when the tool runs on the server. Showing them is rendering, not wiring.
Myth
Data parts are saved forever with the message.
Fact
Regular data parts are in the message, so they are saved when you save messages; transient ones are not stored at all.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Type your messages.
UIMessage<Metadata, DataParts, Tools>takes your own shapes, sopart.dataandpart.outputare typed and the renderer's switch is checked by TypeScript. This is a strong reason to define oneMitraMessagetype shared by the route and the client (/typescriptcourse). - ▸
Use the part's position as the React key only because parts never reorder. When a part has its own id (
data-*withid, tool parts withtoolCallId) prefer that. - ▸
Keep rendering of each part type in its own small component. Phase 6 replaces the text branch with a Markdown renderer, Phase 7 adds cards for tool results, and Phase 8 adds approval buttons. A switch that only picks components stays readable through all of it.
Remember this
- 1
The problem this solves: In Phase 4 a message had one
contentstring. Real AI answers are more than text. A model can 'think' first, call a tool and wait for it, cite a web page, return a file, and you may want to send your own progress updates. Squeezing all that into one string forces you to parse markers out of text. Parts keep each piece separate, labelled and in order. - 2
The shape. A UI message is
{ id, role, metadata?, parts }.partsis an array in the order things happened. Every part has atype. The common ones:{ type: "text", text, state };{ type: "reasoning", text, state };{ type: "tool-<name>", toolCallId, state, input, output }(for a tool namedlookupOrderthe type istool-lookupOrder);{ type: "source-url", sourceId, url, title };{ type: "source-document", ... };{ type: "file", mediaType, url };{ type: "data-<name>", id?, data }; and{ type: "step-start" }, an invisible marker between the steps of a multi-step answer. - 3
Rendering is a loop and a switch.
message.parts.map((part, i) => ...)and a function that picks a component bypart.type. The rule that keeps you safe: a part type you do not know renders nothing. New SDK versions and your own data parts add types; if the switch crashes on an unknown type, one new feature breaks every old saved chat. Never renderpart.textfor a type you did not check. - 4
**
stateon text and reasoning.** A text part is"streaming"while words arrive and"done"when its block ends. You can use it for a cursor on the last streaming part. Tool parts have a longer life:input-streaming,input-available,output-available,output-errorand, with approval (Phase 8),approval-requested,approval-respondedandoutput-denied. Phase 8 renders them in depth. Here you only need to know that they are parts, and that a tool part changes in place as its state moves on. - 5
Reasoning parts. Some models send their working-out before the answer. It arrives as a
reasoningpart. By default the SDK forwards it (sendReasoningdefaults to true); you can turn it off on the server withtoUIMessageStreamResponse({ sendReasoning: false }). Show it collapsed, or not at all. It is the model's scratch work, it can be wrong or odd, and many products hide it (Topic 6.7). - 6
Data parts: your own progress and results. On the server you can write
{ type: "data-progress", id: "p1", data: { step: "Looking up" } }into the stream (withcreateUIMessageStream). The browser gets a part of that type. Writing again with the sameidupdates the same part instead of adding a new one. A part markedtransient: trueis not stored in the message at all and only reaches theonDatacallback, which suits one-off toasts. Source parts (sendSources: trueon the response) carry the pages an answer drew on (Topic 6.6). - 7
Edge cases that surprise people. An empty model reply creates no assistant message at all: the status goes back to
readyand the last message is still the user's, so you must handle 'nothing came back'. A message can hold only astep-start, or only a data part, and have no text to show. And two things written to the stream before thestartchunk end up in a message of their own, as the second breakIt shows. - 8
File parts, briefly. A
filepart is{ type: "file", mediaType, url, filename? }. Theurlis either a hosted address or a data URL (the file's bytes written into the address itself). The model can produce one (a generated image) and a user message can carry one (an attachment, Topic 11.1). Render it by checkingmediaTypefirst: showimage/*in an<img>with analt, anything else as a download link, and render nothing for amediaTypeyou do not handle. Treaturlas untrusted, like a source URL (Topic 6.4).
Explain it without notes
Why did the AI SDK move from a single content string to a list of parts?
How should a renderer treat a part type it does not recognise, and why?
What is the difference between a normal data part, a data part with the same id, and a transient data part?
What happens in the UI if the model returns an empty reply?
Practice
Write a function describe(message) that returns a one-line text for a message list preview: the first non-empty text part shortened to 40 characters, or 'Reply with no text' if there is none.
Add source-url parts to the first example's renderer so they appear as a 'Source: title' link with rel="noopener noreferrer" and target="_blank". Why is it safe to show only http and https URLs?
Trade-offs
- ↔
Parts make rendering precise and extensible, but every consumer (screen, saved chats, analytics, tests) must now understand the list. A
plainText(message)helper in one place keeps the rest of the code simple. - ↔
Streaming your own data parts gives a rich progress UI, but each one is a contract between your server and every client version in the wild. Keep their shapes small and versioned, and ignore unknown ones.
Done when you can
I can list the common part types and say what each carries.
I can render a message with a loop and a switch that ignores unknown types.
I can say how a tool part changes while its tool runs.
I can write a data part, update it by id, and send a transient one.
I can handle a reply with no assistant message and a message with nothing visible.