Let’s check how everything works. We ask:
who is the best football player in the world
The chat runs with a chat ID in the params and the answer streams in, and a refresh keeps the conversation on the page. A follow-up question appends correctly and the context is retained. Refreshing again preserves everything.
If we open data/chats.local.json, we can see a single chat recorded that includes the question “who’s the best football player in the world” along with its ID.
On the backend POST request, we retrieve the existing chat with getChat. If it doesn’t exist, we create one with createChat; otherwise we append the user’s most recent message with appendToChatMessages:
At the end of the stream, we wait for onFinish on the UIMessageStreamResponse and append only the final AI message using responseMessage:
On the frontend, inside useChat, the chat ID comes from the search params or falls back to a backup ID. This ensures we always control the ID that’s sent. Existing messages are hydrated by passing data.messages into useChat. If a chat ID isn’t present during submission, we set it in the search params and refresh the backup ID so the thread remains consistent.
let chat = await getChat(id);if (!chat) {chat = await createChat(id, messages);} else {await appendToChatMessages(id, [mostRecentMessage]);}
return result.toUIMessageStreamResponse({onFinish: async ({ responseMessage }) => {await appendToChatMessages(id, [responseMessage]);},});