Command Palette

Search for a command to run...

Hectal
PHASE 5Intermediate ~27 min· topic 8 of 9

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 toUIMessageStreamResponse so onEnd can return the full list.
onEnd
A callback that runs when the stream ends, with messages, isAborted and responseMessage. onFinish is the older name.
Initial messages
The messages option of useChat: 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 onEnd runs 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.

Save, load, continuediagram
Rendering diagram…

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

    Drop the key

    In the first example remove both key=... attributes and change the second render's initial to [], then run it.

    The line after the reload still says 2 messages: React reused the old hook, so the new initial was ignored. With the keys back, it says 0 messages. Use a changing key (the chat id) to get a new hook.

  2. 2

    Remove consumeSseStream

    In the third example delete the consumeSseStream line.

    The server line never prints and nothing is saved, so db.get("chat-1") is undefined and the reload ends with Uncaught SyntaxError: "undefined" is not valid JSON. A stopped answer is lost entirely.

  3. 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 in error with statusCode 400. One bad stored message makes the whole chat unusable, which is why a migration or a 'start a new chat' button matters.

Code & diagrams

Save in onEnd, load as initial messages, continue

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

Press Run to see the page.

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.
Stored data is untrusted: validate before use

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

Press Run to see the page.

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 messages
A stopped answer: saved with a flag, shown as Stopped

Without the flag the reopened chat would show 'one two three' as a finished answer.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

(server) saved, aborted = true
on screen when stopped: "one two three"
reopened: "one two three" | label: Stopped
resume (real app, concept)ts

Not 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 mount

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

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.

terminal
$ The console, last lines
── what you'll see ──
(server) TypeError: Cannot read properties of undefined (reading 'some')
error 500

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. onEnd runs 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. 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. 2

    Save in the route. Pass originalMessages (the validated messages you received) to toUIMessageStreamResponse, and an onEnd callback. When the stream ends, onEnd gets messages: the full conversation including the new assistant message, in UI message form, ready to store as JSON under the chat id. (onFinish is the older name.) isAborted tells you whether the user stopped.

  3. 3

    Load into the hook. useChat({ id, messages: saved }). The messages option 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, give useChat a different id and key the component by that id so a fresh hook is created. Also pass the chat id with every request (the SDK already does: id is in the body) so the route knows which row to save.

  4. 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 convertToModelMessages or to useChat, run safeValidateUIMessages({ messages }) (it returns { success, data } or { success: false, error }). A message with the old content string, a missing parts, an unknown role or an empty parts array 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. 5

    Stopped and failed answers. If the user stops, onEnd still runs, but **only if you pass consumeSseStream: consumeStream** (Topic 5.2); then isAborted is true and 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 get onEnd with 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. 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's resumeStream docs 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 treats stop() (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, and abortSignal: req.signal would 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. 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

01

Where should chats be saved when you use the AI SDK, and with which callback?

02

How do you show a saved chat in useChat, and how do you switch to another chat?

03

Why validate stored messages, and what do you do when validation fails?

04

What must be true for a stopped answer to be saved, and how do you mark it?

Practice

01

Write migrate(message) for a stored message that has content (string) and no parts, returning a valid UI message.

02

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 onEnd with originalMessages.

  • I can load saved messages into useChat and 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.