Topic 5.8
Persisting Chats with the SDK
In one line
useChat keeps messages in memory only. To keep a chat you save the messages on the server when an answer ends (onEnd), load them into useChat as the initial messages, and validate them on the way back in. Stopped answers need a flag, old saved shapes need a plan, and resuming a live stream has hard limits.
Think of it like this
A notebook at a help desk. While you talk, the agent jots the conversation down. Next week a different agent opens the notebook and carries on. If the agent only remembered the chat in their head, it would be gone at the end of the shift. useChat is the head; the database is the notebook.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Persist
- Save so it survives a reload or a server restart.
- originalMessages
- The messages you received, passed to
toUIMessageStreamResponsesoonEndcan return the full list. - onEnd
- A callback that runs when the stream ends, with
messages,isAbortedandresponseMessage.onFinishis the older name. - Initial messages
- The
messagesoption ofuseChat: the conversation the hook starts with. - safeValidateUIMessages
- An SDK function that checks a list of messages and returns success or an error, without throwing.
- consumeSseStream
- An option that keeps the SDK reading its own copy of the stream so
onEndruns even after the browser disconnects. - Resume
- Reconnecting to an answer that is still being generated after a page reload.
- Migration
- Converting stored data from an old shape to the current one.
Step by step
01Save, load, continue
The first example is the whole loop. The route validates the incoming messages, answers, and saves the full conversation under the chat id in onEnd. Then the page is re-created with the saved messages as messages, and the conversation continues: the model sees the old turns, and the store grows to four messages.
The stored form is plain JSON, so the example round-trips it through JSON.stringify and JSON.parse exactly as a database would.
02Bad rows
The second example runs eight kinds of stored data through safeValidateUIMessages: a good list, an old content string, a message with no parts, an unknown role, a text part without text, an empty parts array, an unknown part type, and something that is not a list. Each bad one is rejected with a TypeValidationError, and the example shows the route turning that into a 400 instead of sending garbage to the model.
03A stopped answer, saved with a flag
The third example stops an answer after its third word, which the script waits for in the message list, not on a timer. The server's onEnd runs with isAborted: true because of consumeSseStream, and stores the partial text with metadata.stopped. When the chat is reopened, the screen shows 'Stopped' next to the saved text.
04Resume, in words
Resume is not run live here, because it needs a store that outlives one request. The route files in the non-live block show the two routes: the POST that starts an answer and keeps its stream, and the GET that replays it for resume: true.
Try it yourself
- 1
Drop the key
In the first example remove both
key=...attributes and change the second render'sinitialto[], then run it.The line after the reload still says
2 messages: React reused the old hook, so the newinitialwas ignored. With the keys back, it says0 messages. Use a changing key (the chat id) to get a new hook. - 2
Remove consumeSseStream
In the third example delete the
consumeSseStreamline.The server line never prints and nothing is saved, so
db.get("chat-1")isundefinedand the reload ends withUncaught SyntaxError: "undefined" is not valid JSON. A stopped answer is lost entirely. - 3
Corrupt one row
At the end of the first example add:
root.render(<App key="bad" initial={[{ id: "x", role: "user", content: "old shape" }]} />); await wait(50); await chat.sendMessage({ text: "go" }); await wait(60); console.log(chat.status, chat.error.statusCode);The route validates and answers 400, and the console prints
error 400: the hook is inerrorwithstatusCode400. One bad stored message makes the whole chat unusable, which is why a migration or a 'start a new chat' button matters.
Code & diagrams
The second answer says it sees two questions: the restored first question and the new one both reached the model. The key on each App makes React create a fresh hook, which is how a different chat is shown.
Preview
Console
Nothing logged yet.Expected console output
(server) saved 2 messages for chat-1
first visit: Hello | I can see 1 question(s) from you.
after reload: 2 messages, status ready
(server) saved 4 messages for chat-1
continued: Hello | I can see 1 question(s) from you. | Still there? | I can see 2 question(s) from you.Reject, or migrate and validate again. Never pass unchecked stored messages to the model call: a malformed row would turn into a 500 for the user who opens it.
Preview
Console
Nothing logged yet.Expected console output
a good list: ok, 2 messages
old shape (content string): 400 This chat can not be opened (AI_TypeValidationError)
message without parts: 400 This chat can not be opened (AI_TypeValidationError)
unknown role: 400 This chat can not be opened (AI_TypeValidationError)
text part without text: 400 This chat can not be opened (AI_TypeValidationError)
empty parts array: 400 This chat can not be opened (AI_TypeValidationError)
unknown part type: 400 This chat can not be opened (AI_TypeValidationError)
not a list: 400 This chat can not be opened (AI_TypeValidationError)
--- after migrating the old shape
old shape, migrated: ok, 1 messagesWithout the flag the reopened chat would show 'one two three' as a finished answer.
Preview
Console
Nothing logged yet.Expected console output
(server) saved, aborted = true
on screen when stopped: "one two three"
reopened: "one two three" | label: StoppedNot runnable here: resume needs a store that outlives one request (the SDK guide uses Redis with the `resumable-stream` package). This shows the shape of the two routes: `saveStream`, `findRunningStream` and `saveChat` are your own functions. This route does not pass `abortSignal`, on purpose.
// app/api/chat/route.ts (POST starts the answer and records its stream under the chat id)
export async function POST(req: Request) {
const { id, messages } = await req.json();
const result = streamText({ model, messages: await convertToModelMessages(messages) });
return result.toUIMessageStreamResponse({
originalMessages: messages,
onEnd: ({ messages }) => saveChat(id, messages),
consumeSseStream: ({ stream }) => saveStream(id, stream), // e.g. into Redis, chunk by chunk
});
}
// app/api/chat/[id]/stream/route.ts (GET: is an answer for this chat still running?)
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const live = await findRunningStream(id);
if (!live) return new Response(null, { status: 204 }); // nothing to resume
return createUIMessageStreamResponse({ stream: live });
}
// browser
const chat = useChat({ id, messages: saved, resume: true }); // asks GET /api/chat/<id>/stream on mountBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Skip the validation
In the first example delete the safeValidateUIMessages lines and write const checked = { data: messages };. Then add the corrupt-row line from the third tryIt at the end.
Myth vs fact
Myth
useChat saves the chat for me.
Fact
It holds messages in memory. Saving is your route's or your client's job.
Myth
Changing the messages option loads another chat into the same hook.
Fact
It is only the initial value. Use a different id and a new key for the component.
Myth
Saved messages are safe because I wrote them.
Fact
They are old data from older code, and sometimes edited data. Validate on read.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Save the user's message before the model starts, not only in
onEnd, if losing a question would hurt.onEndruns after the answer; a crash in between loses the turn. - ▸
Store messages as an append-only list with an order number rather than rewriting one big JSON blob, once chats get long: two tabs saving the same blob overwrite each other (Topic 4.4).
- ▸
A regenerated answer replaces the last assistant message; your save must replace it too (by id), not append a duplicate.
- ▸
Chat titles are a second model call. Generate them after the first answer, in the background, with a small model, and never block the chat on them.
Remember this
- 1
The problem this solves: Phase 4.4 saved chats by hand in
localStorage. With the SDK there is a cleaner place to save them: the route, because the route already sees the whole conversation and the finished answer. This topic shows the three moves (save, load, validate) and the cases that break them: stopped answers, corrupted rows, and a server that loses data. - 2
Save in the route. Pass
originalMessages(the validated messages you received) totoUIMessageStreamResponse, and anonEndcallback. When the stream ends,onEndgetsmessages: the full conversation including the new assistant message, in UI message form, ready to store as JSON under the chat id. (onFinishis the older name.)isAbortedtells you whether the user stopped. - 3
Load into the hook.
useChat({ id, messages: saved }). Themessagesoption is the initial state: it is read when the hook is created for that chat, and changing the prop later does not replace what the hook holds. To show a different chat, giveuseChata differentidand key the component by that id so a fresh hook is created. Also pass the chat id with every request (the SDK already does:idis in the body) so the route knows which row to save. - 4
Validate in both directions. Stored data is input too. It can be old (a shape from last year's app), cut by a bug, or edited. Before you give saved messages to
convertToModelMessagesor touseChat, runsafeValidateUIMessages({ messages })(it returns{ success, data }or{ success: false, error }). A message with the oldcontentstring, a missingparts, an unknown role or an emptypartsarray is rejected. Decide what a failure means: return a 400 and show 'This chat can not be opened', or migrate the old shape with a function and validate again. - 5
Stopped and failed answers. If the user stops,
onEndstill runs, but **only if you passconsumeSseStream: consumeStream** (Topic 5.2); thenisAbortedistrueand the assistant message holds the partial text. Save it, with a flag of your own (metadata: { stopped: true }or a column in your table), so that when the chat is reopened the UI can show 'Stopped' (Topic 5.6). If the stream breaks with an error you getonEndwith the error outcome; decide whether a half answer is worth keeping. Without the flag, a reopened chat shows a half sentence as if it were complete. - 6
Resuming a live stream: the idea and its limits. If the user reloads the page while an answer is still being written, the browser's request dies.
useChat({ resume: true })asks the route (a GET to/api/chat/<id>/stream) whether an answer for that chat is still running, and if you return a stream, it continues. This needs a store that holds the stream chunks outside one server process (a Redis-style store) and code in a GET route to replay them; the SDK'sresumeStreamdocs explain the pieces. The page also reconnects when it becomes visible again after a dropped network connection. Resume changes what stop means: in a resumable setup the SDK treatsstop()(and closing the tab) as a disconnect, so the model keeps generating and the client could reconnect to it. A Stop button then needs its own stop endpoint that saves the partial answer, cancels the work and clears the active stream, andabortSignal: req.signalwould cancel the very stream you want to resume. Most apps skip resume, save the finished message, and show the partial text on reload. Long-running agent work is where resume pays off (Topic 14.2). - 7
What to store. Per chat: id, owner (from the verified session, never from the body), title, created and updated times. Per message: the whole UI message as JSON (id, role, parts, metadata) plus an order number. Keep parts as JSON rather than splitting them into columns, because part types will grow. Add an index on the owner so a user can only list their own chats, and check ownership on every read and write (Topic 9.5).
Explain it without notes
Where should chats be saved when you use the AI SDK, and with which callback?
How do you show a saved chat in useChat, and how do you switch to another chat?
Why validate stored messages, and what do you do when validation fails?
What must be true for a stopped answer to be saved, and how do you mark it?
Practice
Write migrate(message) for a stored message that has content (string) and no parts, returning a valid UI message.
Make the first example's route refuse a chat id that is longer than 40 characters with a 400.
Trade-offs
- ↔
Saving whole messages as JSON is flexible and survives new part types, but you can not query inside them cheaply. Add columns only for what you search or sort by (owner, title, updated time).
- ↔
Resume gives a seamless reload, at the cost of a stream store, a GET route and a separate stop endpoint (a plain
stop()only disconnects). Saving the finished message and showing partial text is far simpler.
Done when you can
I can save a conversation in
onEndwithoriginalMessages.I can load saved messages into
useChatand switch chats with a key.I can validate stored messages and decide between migrating and refusing.
I can save a stopped answer with a flag using
consumeSseStream.I can explain what resume needs and why it changes what Stop means.